Skip to content
GenerateSpecs home.mdDownload this spec as Markdown.htmlDownload this spec as a single self-contained HTML file
otherPUBLIC

Malka Debleds — Entreprise Vivante Landing Page

Immersive French landing page for Malka Debleds' coaching practice, with a working contact flow and Docker deployment.

18,393 lines228,258 words20 sectionsgenerated in 1h 37mOct 16, 2026

Malka Debleds — « Entreprise vivante » : cahier des charges de la landing page #

Version : 1.0 — finale · Langue du site : français · Périmètre : MVP premium entièrement fonctionnel

Présentation #

Ce document est le cahier des charges complet de la landing page de Malka Debleds, professionnelle de l'accompagnement vibratoire et stratégique des entrepreneurs. Il décrit un site de production — et non une maquette — construit avec Next.js (App Router), React, TypeScript strict, Tailwind CSS et Motion for React, et déployé avec Docker Compose sur un VPS Linux.

Le site transforme une visite en expérience de découverte, de clarification et de prise de contact : une hero cinématographique « CLARTÉ » (paysage montagneux au coucher du soleil, portrait réel de Malka, grand mot « CLARTÉ » en transparence, progression Écouter → Explorer → Clarifier → Décider), une philosophie éditoriale « Entreprise vivante », une boussole de méthode interactive, quatre accompagnements configurables, une boussole d'orientation sans diagnostic, une présentation de Malka, des témoignages réels uniquement, un contact par WhatsApp et par formulaire sécurisé, et un footer immersif « Passage & Clarté ».

Le document est écrit pour être exécuté tel quel par un agent de développement IA ou une équipe, sans question préalable : chaque valeur par défaut est une décision, chaque préoccupation a une section propriétaire et les autres sections y renvoient par numéro (« voir §9.4 »). Rien n'y est inventé : les éléments que seule Malka peut fournir (photos, identité légale, textes et consentements des témoignages, clé d'API e-mail, domaine) ont un emplacement, un format et un garde-fou de build qui empêche toute mise en production sans eux (voir §1.3).

Table des matières #

  1. Avant de commencer — décisions par défaut et éléments fournis par Malka
  2. Vision produit, objectifs et publics cibles
  3. Exigences fonctionnelles et non fonctionnelles
  4. Architecture UX, parcours et wireframes
  5. Design system
  6. Spécifications des sections et interactions
  7. Contenus éditoriaux (copy deck)
  8. Système d'animation Motion
  9. Architecture logicielle, modèle de contenu et contrats d'API
  10. Sécurité, RGPD et conformité
  11. SEO, accessibilité et performance
  12. Arborescence du projet et conventions de code
  13. Configuration et variables d'environnement
  14. Architecture Docker et déploiement
  15. Stratégie de tests et critères d'acceptation
  16. Plan de développement par étapes
  17. Risques, hypothèses, dépendances et arbitrages
  18. Instructions pour l'agent exécutant

1. Avant de commencer — décisions par défaut et éléments fournis par Malka #

Cette section est la première à lire. Elle dit comment utiliser le document, liste toutes les décisions prises par défaut (l'exécutant ne les remet pas en question) et tout ce que Malka doit fournir avant la mise en production, avec l'emplacement exact où chaque élément se branche et le garde-fou automatique qui bloque la production tant qu'il manque.

1.1 Comment lire et exécuter ce document #

1.1.1 Nature du document #

Ce document est une spécification d'exécution complète : un agent de développement (ou un développeur) doit pouvoir construire le site de bout en bout sans poser de question et sans autre source d'information. Il est autosuffisant. Quand une valeur n'est pas fournie par Malka, le document définit l'emplacement, le format, la validation et le garde-fou de build — il n'invente jamais la valeur.

Vocabulaire :

Terme Sens dans le document
Malka Malka Debleds, propriétaire du site et seule source de vérité sur son identité, ses offres, ses témoignages, sa biographie et ses coordonnées légales.
Exécutant L'agent ou le développeur qui implémente ce document.
Visiteur Toute personne qui consulte le site.
Site Le site Web décrit ici : une page d'accueil (/), deux pages légales, une page 404, une page d'erreur et deux points d'API.
Garde Contrôle automatisé (build, démarrage ou test) qui échoue explicitement quand un élément requis manque. Liste complète en §1.3.3.

1.1.2 Ordre de lecture et d'exécution #

L'ordre détaillé de lecture est fixé en §18.2 ; il prévaut. En résumé :

  1. Lire la section 1, puis la section 2 (périmètre, publics, ce qui manque et comment le traiter). La section 3 est le tableau de référence des exigences : elle se consulte tout au long du projet.
  2. Lire la section 18 (consignes à l'exécutant) avant d'écrire la première ligne de code.
  3. Lire §9.1 (pile et versions) et la section 13 (variables d'environnement), puis la section 12 (arborescence et conventions), puis §9.2 à §9.8.
  4. Lire les sections 5 (jetons de design) et 8 (animation), puis la section 4 (architecture UX, ancres) et §6.1 à §6.11 (spécification des sections), puis la section 7 (textes français), au moment d'implémenter chaque jalon qui les concerne.
  5. Lire les sections 10 et 11 (sécurité, RGPD, SEO, accessibilité, performance) et la section 14 (Docker et déploiement) avant le jalon correspondant.
  6. Exécuter le plan de la section 16 dans l'ordre, jalon par jalon. Un jalon n'est terminé que lorsque ses critères d'acceptation (section 15 et critères AC-… de chaque section) passent.
  7. La section 17 (risques, hypothèses, arbitrages) se lit avant toute déviation : elle explique pourquoi une option a été écartée.

1.1.3 Conventions d'écriture #

Convention Règle
Mots d'exigence DOIT / NE DOIT PAS = obligatoire et testable ; DEVRAIT = attendu sauf raison consignée dans DECISIONS.md ; PEUT = facultatif.
Langue Le document est en français. Les identifiants de code, chemins, variables d'environnement, clés JSON et noms de tests restent en anglais. Toutes les chaînes visibles par le visiteur sont en français.
Références croisées « voir §9.4 » = section 9.4 de ce document. Aucune référence à un fichier extérieur au dépôt de l'exécutant.
Identifiants FR-xx (fonctionnelle), NFR-xx (non fonctionnelle), D-xx (décision par défaut, §1.2), M-xx (élément fourni par Malka, §1.3.2), G-00 à G-11 (garde de build ou de démarrage, §1.3.3), GT-01 à GT-05 (test d'un garde de production, §15), AC-N.M-nn (critère d'acceptation de la section N.M), DEP-xx (dépendance externe) et ARB-xx (arbitrage) en §17, DK-x (décision d'exploitation) en §14, DEC-NNN (entrée de DECISIONS.md, §18.7).
Priorités MVP = livré au lancement ; Évolution = prévu par l'architecture, non livré au lancement.
Valeurs de version Les lignes de version des dépendances sont énoncées uniquement en §9.1. Toute autre section renvoie à §9.1.
Exemples de texte Tout texte français d'exemple dans une section autre que §7 est illustratif ; le texte de référence est dans §7 (copy deck).

1.1.4 Principe de précédence #

Chaque sujet a une seule section propriétaire. En cas d'écart apparent entre deux sections, la valeur de la section propriétaire l'emporte et l'exécutant aligne l'autre occurrence.

Sujet Section propriétaire
Pile technique et versions §9.1
Schémas de contenu (fichiers de données typés) §9.3
Clés des fichiers de contenu src/content/fr/*.ts §9.3.3 (schémas) ; §7 en fournit les valeurs, clé pour clé
Contrat d'API et enveloppe d'erreur §9.4
E-mail transactionnel §9.5
Limitation de débit et anti-spam §9.6 (justification de sécurité en §10)
Journalisation §9.7
Événements d'analytique §9.8
Variables d'environnement §13 (seul endroit qui les liste toutes)
Jetons de design (couleurs, typographie, espacements) §5
Jetons z-index, rayons, ombres §5.7
Jetons et variantes d'animation §8
Ancres et identifiants de sections §4.2
Textes français §7
Matrice de tests §15
Jalons §16
Arborescence, noms de fichiers, scripts package.json §12
Noms de fichiers et de hooks §12.2
Gardes de build et de démarrage Liste en §1.3.3 ; règles de contenu en §9.3.7
Journal des décisions et des écarts (DECISIONS.md) §18.7

1.1.5 Règle d'or : ne jamais inventer #

L'exécutant NE DOIT PAS produire, même provisoirement dans une branche destinée à la production :

  • un texte de témoignage, un nom de client autre que « Stéphanie (Steff Beauté Plurielle) » et « Émilie » ;
  • une statistique autre que « 300+ personnes accompagnées » et « 50+ personnes formées » ;
  • une identité légale (SIRET, adresse, statut, hébergeur), un prix, une durée de séance, une certification, une récompense, une mention presse ;
  • un fait biographique sur Malka ;
  • un visage généré ou retouché par IA.

Quand un élément manque, l'exécutant utilise l'élément de développement prévu (§1.2, D-23 et D-24), la mise en production est bloquée par un garde (§1.3.3), et la valeur réelle est ajoutée par Malka dans le fichier indiqué en §1.3.2.

1.1.6 Les trois environnements #

La variable APP_ENV (définie en §13) pilote le comportement des gardes et des éléments de développement.

Comportement development staging production
Images de repli public/images/dev-fallback/ autorisées Oui Oui Non : le build échoue (G-02)
Bandeau « Informations légales à fournir par Malka avant mise en production » sur les pages légales si une valeur manque Oui Oui Jamais : le build échoue avant (G-01)
Garde de validation de contenu (G-05) Avertissement Avertissement Bloquant
EMAIL_DRY_RUN par défaut true true (passer explicitement à false pour l'essai d'envoi réel de la recette P5) false ; la valeur true est interdite et fait échouer le démarrage (G-04)
Indexation Interdite : robots.txt avec Disallow: /, balise noindex, en-tête X-Robots-Tag Interdite : mêmes trois protections Autorisée
sitemap.xml Liste les trois URL Liste les trois URL Liste les trois URL
En-tête HSTS Non émis (HTTP local) max-age=300, sans includeSubDomains max-age=31536000; includeSubDomains
Analytique none none ou umami none ou umami selon §13
SITE_URL http://localhost:3000 URL HTTPS de préproduction URL HTTPS publique définitive (G-06)

Les tests automatisés (NODE_ENV=test) utilisent aussi EMAIL_DRY_RUN=true. Les gardes qui lisent le système de fichiers (G-05, G-07, G-08, G-09) ne s'exécutent qu'au build ; ils ne s'exécutent jamais au démarrage ni pendant la vie du conteneur.

1.1.7 Définition de « terminé » commune à tout livrable #

Un livrable (composant, route, jalon) est terminé quand toutes les conditions suivantes sont vraies :

  1. lint, typecheck et tests unitaires/intégration passent (commandes en §14 et §15).
  2. Aucun avertissement React ni erreur de console au chargement de / ni au parcours des interactions du composant.
  3. Le composant est vérifié aux largeurs 320, 390, 834, 1024, 1440 et 1920 px (voir §4.6), sans défilement horizontal ni chevauchement (voir §4.5).
  4. Le comportement prefers-reduced-motion: reduce est vérifié (voir §8).
  5. La navigation au clavier seul et l'analyse automatique d'accessibilité (axe) n'indiquent aucune violation de niveau sérieux ou critique.
  6. Les états de chargement, vide, erreur, succès et désactivé applicables sont implémentés (liste des états à couvrir par section en §6).
  7. Aucune valeur inventée n'est présente dans les fichiers de contenu pour l'environnement visé (§1.1.5).

1.2 Décisions par défaut #

Toutes les décisions suivantes sont fermées. L'exécutant les applique sans les rouvrir ; une déviation nécessite une entrée dans DECISIONS.md (§18.7) et ne peut pas contredire la section propriétaire.

ID Décision Valeur retenue Justification Section propriétaire
D-01 Langue Français uniquement ; <html lang="fr"> ; contenu sous src/content/fr/ ; accès via getContent(locale) ; pas de préfixe de langue dans les URL Exigence de lancement monolingue ; extension i18n possible sans refonte §9.3
D-02 Fournisseur d'e-mail Brevo (API REST transactionnelle appelée avec fetch, sans SDK), derrière une interface EmailProvider Société française, données hébergées dans l'UE (RGPD) ; interface qui permet d'ajouter un autre fournisseur plus tard §9.5
D-03 Stockage Aucune base de données, aucun stockage de message, aucun cookie, aucun localStorage ni sessionStorage, sauf l'indicateur d'opposition umami.disabled, écrit uniquement à la demande explicite du visiteur lorsque l'analytique est activée (§10.6.4) Exigence : seul l'e-mail envoyé contient les données du visiteur ; réduit la surface RGPD ; l'opposition à la mesure d'audience doit rester possible §10
D-04 Limitation de débit En mémoire, fenêtre glissante par IP, instance unique, interface RateLimiter remplaçable (Redis possible plus tard) Infrastructure minimale demandée ; un seul conteneur au lancement §9.6
D-05 Rendu Génération statique de /, /mentions-legales, /politique-de-confidentialite au build ; seules /api/contact et /api/health sont dynamiques Performance maximale, coût d'exploitation minimal ; toute modification de contenu = nouveau build §9
D-06 Mise à jour du contenu Édition des fichiers de données typés de src/content/fr/ → validation Zod au build → déploiement reproductible ; pas de CMS ni d'interface d'administration Exigence : pas de back-office ; contenu validé avant publication §9.3
D-07 Analytique ANALYTICS_PROVIDER=none par défaut ; Umami (sans cookie) activable ; script chargé après l'hydratation avec les attributs figés data-do-not-track="true", data-exclude-search="true" et data-exclude-hash="true" ; aucune bannière de consentement ; aucun autre traceur Pas de cookies publicitaires ; cadre d'exemption de mesure d'audience analysé en §10 ; ni la situation de la boussole ni l'offre présélectionnée ne doivent atteindre la mesure §9.8, §10
D-08 HTTPS et proxy inverse Profil Compose proxy (Caddy, HTTPS automatique, redirection www → apex) pour un VPS nu ; sans ce profil pour Coolify ou tout proxy existant Les deux modes d'exploitation demandés sont couverts sans dupliquer la configuration §14
D-09 Polices Fraunces (titres, serif éditorial, variable ; romain et italique, l'italique en seconde instance next/font sans préchargement) et Manrope (interface et texte, variable), via next/font/google auto-hébergées au build, sous-ensembles latin et latin-ext, display: 'swap', font-synthesis: none Aucune requête vers un service tiers à l'exécution ; pas de décalage de mise en page (métriques de repli ajustées) ; aucune fausse italique §5
D-10 Offre mise en avant featured: true uniquement pour seance-clarification ; configurable dans offers.ts Offre d'entrée la plus lisible ; la mise en avant ne change pas l'ordre de lecture §6.5, §9.3
D-11 Mappage orientation → offre decision-bloquee → seance-clarification ; fatigue → lecture-globale ; repositionnement → lecture-globale ; transition → suivi-transition Chaque situation mène à une offre cohérente avec son niveau d'engagement, sans diagnostic §6.6, §9.3
D-12 Cibles des CTA de navigation CTA du header « Faire le point » → #contact ; CTA principal du hero « Prendre rendez-vous » → #contact ; CTA secondaire « Découvrir la méthode » → #methode ; logo → #accueil (→ / sur les pages légales) Un seul point de conversion (section Contact) ; WhatsApp y est le premier élément §4.2, §6.1
D-13 WhatsApp Lien https://wa.me/33667295179?text=… (message encodé par encodeURIComponent), nouvel onglet avec rel="noopener noreferrer", aucun widget ni SDK Aucun script tiers ; fonctionne sur mobile (application) et sur ordinateur (WhatsApp Web) §9.3, §6.5
D-14 Logo Marque-texte « Malka Debleds » composée en Fraunces ; remplaçable par un SVG fourni par Malka Aucun logo graphique n'existe ; le SVG optionnel se branche sans modifier les composants §5, §6.1
D-15 Témoignages au lancement Les deux entrées (stephanie, emilie) sont livrées avec published: false, consent.status: 'pending', quote: '' ; section et lien masqués tant qu'aucune n'est publiable ; un témoignage est publiable si et seulement s'il est publié, consenti par écrit (date valide) et si sa citation est non vide (700 caractères au plus) Aucun avis sans texte vérifié et consentement écrit §6.8, §9.3
D-16 Défilement automatique du slider de témoignages Aucun défilement automatique ; navigation par boutons précédent/suivant (en boucle), indicateurs et clavier ; slider uniquement à partir de deux témoignages publiables, une citation unique est affichée seule Critère WCAG 2.2.2 (mouvement, pause, arrêt) ; lecture sereine §6.8
D-17 Formulaire de contact Champs : nom, e-mail, téléphone (facultatif), offre concernée (facultatif), situation (facultatif, liste visible), message (jamais prérempli), case d'accusé de lecture de la politique de confidentialité, champ piège ; accusé de réception au visiteur activé par défaut (SEND_ACKNOWLEDGEMENT=true) Qualification minimale d'une demande ; confirmation rassurante sans écho de contenu saisi §9.4, §9.5, §6.9
D-18 Anti-spam Champ piège (honeypot), délai minimal de remplissage 3 s, validité 24 h, vérification d'origine, limitation de débit (dont un plafond de tentatives par IP et des plafonds propres à l'accusé de réception : 2 par adresse et 5 par IP sur 24 h, 30 par heure au total) ; abandon silencieux (réponse 200 sans e-mail) pour le spam Pas de CAPTCHA tiers (pas de pistage, pas de friction) ; l'accusé ne peut pas servir à écrire à des tiers §9.4, §9.6
D-19 Seuil du menu mobile Menu « burger » en dessous de 1280 px (mobiles, tablettes et petits ordinateurs) ; liens inline à partir de 1280 px ; le CTA « Faire le point » reste visible dans la barre dès 640 px ; l'en-tête est toujours visible (aucun masquage au défilement) 7 liens, le logotype et le CTA ne tiennent pas en dessous de 1280 px sans dégrader la lisibilité §6.1, §4.6
D-20 Composition du hero sous 1024 px Dans l'ordre : surtitre, titre, description, CTA, puis portrait dans une bande encadrée (16:10 de 768 à 1023 px, 9:10 en dessous), mot CLARTÉ réduit derrière le portrait dans la bande, chiffres de confiance, rail de méthode horizontal à 4 étapes Aucun texte sur image chargée ; aucun chevauchement ; titre et CTA visibles dès le premier écran §4.4.2, §6.2
D-21 Parallaxe Active uniquement à partir de 1024 px, pointeur fin et mouvement non réduit ; désactivée sinon ; au défilement seulement (aucune réaction au pointeur) ; le texte du hero ne bouge jamais Performance mobile et confort ; conforme à prefers-reduced-motion §8
D-22 Thème Un seul thème visuel (sections sombres vertes et sections claires ivoire) ; pas de bascule sombre/clair Identité éditoriale maîtrisée ; hors périmètre §5
D-23 Éléments visuels de développement Paysages SVG en dégradé et formes abstraites sans visage dans public/images/dev-fallback/ (hero-far, hero-mid, hero-near, hero-mobile, hero-portrait, about-portrait, au format .svg) ; les schémas d'images acceptent le SVG en développement, les contrôles de format, de taille et de ratio sont des gardes de production ; interdits en production (G-02) Permet de construire avant réception des visuels réels sans jamais recréer un visage §5, §6.2
D-24 Données de développement Les valeurs d'exemple nécessaires au développement se limitent à des textes de structure sans prétention factuelle ; aucun nom, chiffre, mention légale ou témoignage fictif Évite qu'une valeur de développement parte en production §9.3
D-25 Images next/image avec sharp à l'exécution (sortie standalone), formats AVIF puis WebP, largeurs responsives, dimensions explicites ; Malka livre un seul fichier maître par emplacement dans public/images/<dossier>/ ; aucune pré-optimisation, aucun manifeste ; seule la couche lointaine du hero est préchargée ; limite mémoire du conteneur 768 Mo Une seule chaîne d'images, poids maîtrisé, zéro décalage de mise en page ; les dimensions minimales sont contrôlées par G-07 §5.9, §11
D-26 Indexation hors production Si APP_ENV ≠ production : robots.txt interdit tout, balise noindex et en-tête X-Robots-Tag ajoutés ; sitemap.xml liste toujours les trois URL Empêche l'indexation d'une préproduction avec des visuels de repli sans casser les contrôles §11
D-27 Prix Libellé visible « Tarif » + montant formaté par Intl.NumberFormat('fr-FR') en euros sans décimales, tel que produit (par exemple « 1 000 € », espaces insécables) ; « Sur devis » pour l'offre personnalisée ; mention de TVA lue dans legal.ts (vatMention) Ne jamais affirmer un régime de TVA non fourni §6.5, §6.11
D-28 Coordonnées Téléphone 06 67 29 51 79 (lien tel:+33667295179), e-mail astheoris.pro@gmail.com, localisation « France — accompagnement à distance » ; source unique site.ts Cohérence partout ; aucune adresse postale publiée sans fourniture légale §9.3
D-29 Réservation en ligne Seul fournisseur actif : WhatsApp ; getBookingAction() (module serveur uniquement) encapsule la cible, les îlots clients reçoivent les liens en propriétés ; Cal.com ajoutable ultérieurement via une valeur de fournisseur et une variable d'environnement, sans refonte Exigence : architecture prête, aucun code Cal.com au lancement §9
D-30 Domaine de référence Domaine apex (sans www) ; www redirige en 301 vers l'apex (profil proxy) ; SITE_URL en HTTPS sans barre finale Évite le contenu dupliqué ; URL canoniques stables §11, §14
D-31 Exploitation Un seul conteneur applicatif (web), utilisateur non root, système de fichiers en lecture seule, redémarrage automatique, journaux JSON sur la sortie standard Simplicité opérationnelle ; pas de Kubernetes §14
D-32 Échec d'envoi du formulaire Réponse EMAIL_DELIVERY_FAILED (502) → l'interface affiche une alternative immédiate : bouton WhatsApp et adresse e-mail cliquable ; le texte saisi est conservé dans le formulaire Aucune demande perdue ; pas de stockage côté serveur §6.9
D-33 Écriture inclusive Pas de point médian ; formulations épicènes ou doublets explicites Compatibilité avec les lecteurs d'écran §7
D-34 Adresse (vouvoiement) Vouvoiement du visiteur ; le site parle de « Malka » à la troisième personne, jamais de « nous » ; première personne réservée aux messages WhatsApp préremplis (écrits par le visiteur) Ton professionnel, chaleureux et respectueux ; une pratique tenue par une seule personne §7
D-35 Typographie française Les textes sont saisis avec des espaces ordinaires ; l'utilitaire frTypo (§5.5.6) applique à l'import du contenu : espace fine insécable (U+202F) avant ;, ! et ?, espace insécable (U+00A0) avant : et à l'intérieur des guillemets « » ; apostrophe typographique ’ ; majuscules accentuées respectées (CLARTÉ, Écouter) ; prix formatés par Intl tels quels ; numéros de téléphone non sécables (white-space: nowrap) Qualité éditoriale §5.5.6, §7
D-36 Fraîcheur du contenu légal legal.ts contient lastUpdated (date ISO) affichée sur les pages légales ; build en production échoue si la date est absente ou future Traçabilité de la version publiée §6.11, §9.3
D-37 Test de validité des chiffres de confiance Les chiffres « 300+ » et « 50+ » restent dans site.ts ; Malka confirme leur exactitude à la date de mise en ligne (registre G-05) Aucune statistique non vérifiée §9.3

1.3 Éléments à fournir par Malka #

1.3.1 Phases de besoin #

Chaque élément est nécessaire à partir d'une phase du projet. Le plan de développement (§16) rattache chaque phase au jalon du même intitulé.

Phase Intitulé Ce qu'elle débloque
P1 Socle technique Rien n'est requis : l'exécutant travaille avec les éléments de développement (D-23, D-24).
P2 Visuels réels Intégration du portrait du hero, du portrait de la section À propos et du paysage ; contrôle des licences.
P3 Contenus validés Textes de la biographie, du copy deck, des offres, des témoignages ; coordonnées légales et durée de conservation des demandes ; confirmation du public (professionnels ou consommateurs).
P4 Chaîne e-mail Envoi réel des demandes de contact à Malka.
P5 Recette sur préproduction Revue complète par Malka sur une URL de préproduction (APP_ENV=staging).
P6 Mise en production Domaine, DNS, VPS, bascule APP_ENV=production.

1.3.2 Tableau des éléments #

Légende de la colonne « Garde » : les identifiants G-xx renvoient au tableau §1.3.3. L'identifiant M-06 n'est pas attribué : le décor du footer est entièrement vectoriel (montagnes et rivière en SVG) et ne demande ni visuel ni licence.

ID Élément Format et exigences Emplacement (dépôt de l'exécutant) Phase Garde bloquant la production
M-01 Portrait de Malka pour le hero Photo réelle, détourée sur fond transparent, format 4:5, ≥ 1 920 × 2 400 px ; un seul fichier maître en WebP avec canal alpha ; visage jamais généré ni modifié par IA ; texte alternatif descriptif validé (voir M-21) public/images/hero/hero-portrait.webp ; référence dans src/content/fr/hero.ts P2 G-02 (aucune référence à dev-fallback), G-07 (fichier présent, dimensions, ratio et alpha conformes), G-08 (droits d'usage consignés)
M-02 Portrait de la section À propos Seconde photo réelle, rectangulaire 4:5, ≥ 1 600 × 2 000 px ; un seul fichier maître en WebP public/images/about/about-portrait.webp ; référence dans src/content/fr/about.ts P2 G-02, G-07, G-08
M-03 Droits d'usage des portraits Preuve écrite que Malka (ou le photographe, ou la plateforme de création) autorise l'usage commercial sur un site Web ; consignée par fichier docs/assets-licences.json (champs : file, kind: 'portrait-malka', rightsHolder, usageAuthorisedOn en YYYY-MM-DD, proofRef) P2 G-08
M-04 Paysage panoramique de montagne au coucher du soleil Licence couvrant l'usage commercial Web ou création originale ; ombres vert forêt à gauche, lumière champagne à droite ; livré en trois calques de parallaxe au format 2:1, ≥ 3 840 × 1 920 px, en WebP sans perte avec canal alpha (hero-far : ciel et sommets lointains, hero-mid : vallée et brume, hero-near : crête proche) plus une version unique aplatie pour mobile et tablette (hero-mobile), paysage 16:10, ≥ 1 600 × 1 000 px, utilisée uniquement dans la bande encadrée sous 1024 px public/images/hero/hero-far.webp, hero-mid.webp, hero-near.webp, hero-mobile.webp ; références dans src/content/fr/hero.ts P2 G-02, G-07, G-08
M-05 Preuve de licence du paysage Pour un visuel sous licence : nom de l'auteur, type de licence, référence ou reçu d'achat, date, mention explicite « usage commercial Web autorisé ». Pour une création originale : déclaration de Malka ou du créateur docs/assets-licences.json (kind: 'licensed' ou 'original', author, licence, proofRef, commercialWebUse: true, validatedOn) P2 G-08
M-07 Image Open Graph 1 200 × 630 px, JPEG ≤ 300 Ko, composée à partir des visuels validés (M-01, M-04) et de la marque-texte ; produite par l'exécutant, validée par Malka public/og/og-default.jpg P5 G-07 (existence, dimensions, poids) ; G-05 (clé ogImage)
M-08 Logo SVG (facultatif) SVG optimisé, sans script ni référence externe, avec <title> ; fond transparent ; lisible à 24 px de haut ; version claire pour fond sombre public/images/logo/malka-debleds.svg ; référence dans src/content/fr/site.ts P2 G-09 (si présent : SVG sûr et valide)
M-09 Identité légale de l'éditeur et durée de conservation des demandes Dénomination ou nom, forme juridique, SIRET (14 chiffres, contrôle de Luhn), adresse postale complète, e-mail, téléphone, directeur ou directrice de la publication. Aucun placeholder. S'y ajoute la durée de conservation des demandes de contact, texte fourni par Malka (à titre indicatif, la CNIL recommande trois ans après le dernier contact pour des données de prospects ; Malka décide) : l'exécutant ne livre jamais de durée par défaut src/content/fr/legal.ts → publisher et privacy.retention P3 G-01
M-10 Hébergeur du site Nom, adresse et téléphone du fournisseur du VPS (ou de la plateforme) tels qu'ils figurent dans ses mentions officielles src/content/fr/legal.ts → host P3 G-01
M-11 Mention relative à la TVA Texte exact qui s'applique à l'activité de Malka (par exemple, si elle relève du régime de franchise en base, la mention légale correspondante). Fournie par Malka ou son comptable ; jamais déduite par l'exécutant src/content/fr/legal.ts → vatMention ; affichée sous la grille d'offres P3 G-01
M-12 Médiateur de la consommation (si applicable) Nom et URL du médiateur ; l'applicabilité à son activité relève de Malka ; si renseigné, l'URL doit être HTTPS valide ; devient obligatoire si Malka confirme vendre à des consommateurs (M-22) src/content/fr/legal.ts → mediator P3 G-01 (format si présent)
M-13 Témoignages de Stéphanie (Steff Beauté Plurielle) et d'Émilie Texte copié mot pour mot depuis le site actuel de Malka après vérification (700 caractères au plus) ; consentement de publication écrit de chaque personne (date YYYY-MM-DD, canal) ; aucune photo de client src/content/fr/testimonials.ts (quote, consent.status: 'granted', consent.grantedOn, consent.channel, published: true) ; preuve du consentement conservée par Malka P3 G-03 (règle de publication) ; sans témoignage publiable, la section n'est pas rendue
M-14 Biographie de Malka Texte de présentation personnelle (parcours) rédigé ou validé par Malka ; aucune certification, aucun diplôme, aucun chiffre non fournis src/content/fr/about.ts P3 G-05 (clé biography)
M-15 Validation du copy deck et des éléments dérivés Relecture et accord de Malka sur : tous les textes de §7 (titres, descriptions, textes de la méthode et de la boussole d'orientation et ses mises en garde, posture et approche de la section À propos, messages d'erreur), le détail des offres (descriptions, duration facultative — jamais une durée non fournie ; format est fixé à « À distance »), le message WhatsApp prérempli de chaque offre, les textes alternatifs des images, les chiffres de confiance (D-37). Ces textes sont des propositions à valider par Malka Registre content-validation.json à la racine du dépôt (voir G-05 ; la clé copyDeck couvre l'ensemble des textes proposés) P3, P5 G-05
M-16 Compte Brevo et clé API Compte Brevo actif ; clé API transactionnelle (v3) avec droit d'envoi d'e-mails ; jamais committée : placée dans l'environnement du conteneur Variable BREVO_API_KEY du fichier .env du serveur (voir §13) P4 G-04 (démarrage refusé en production si absente)
M-17 Domaine d'envoi vérifié et enregistrements DNS Domaine (ou sous-domaine) authentifié dans Brevo ; enregistrements SPF, DKIM (valeurs fournies par Brevo) et DMARC publiés ; adresse d'expédition sur ce domaine (pas une adresse de messagerie grand public) Variable EMAIL_FROM_ADDRESS (§13) ; DNS chez le gestionnaire de domaine P4 G-04 (domaine d'expédition non grand public) ; test d'envoi réel (§1.4)
M-18 Nom de domaine et accès DNS Domaine acheté et administrable ; enregistrements A (et AAAA si IPv6) vers l'IP du VPS ; enregistrement pour www Gestionnaire de domaine ; variable SITE_URL (§13) P6 G-06 (SITE_URL HTTPS, hôte public)
M-19 Accès au VPS Serveur Linux avec Docker et Docker Compose ; accès SSH par clé ; ports 80 et 443 ouverts ; utilisateur non root dans le groupe docker Hors dépôt ; procédure en §14 P6 Compose refuse de démarrer sans .env ; contrôle de santé (§14)
M-20 Instance Umami (facultatif) Instance auto-hébergée ou hébergée : URL du script (HTTPS) et identifiant du site (UUID) Variables ANALYTICS_PROVIDER, UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID (§13) P6 G-10 (cohérence des variables si Umami est activé)
M-21 Texte alternatif et légendes des visuels Rédigés par l'exécutant selon §11, relus par Malka ; décrivent la scène sans décrire d'émotions supposées src/content/fr/*.ts (champ alt) P3 G-05 (clé altTexts)
M-22 Confirmation du public (professionnels ou consommateurs) Confirmation écrite de Malka : ses prestations sont-elles vendues à des professionnels (B2B) ou aussi à des consommateurs (B2C) ? Si elle vend à des consommateurs, des conditions générales avec information précontractuelle et modalités de rétractation, ainsi que le médiateur de la consommation (M-12), deviennent obligatoires ; une page de conditions générales est alors ajoutée (évolution, hors lancement) Entrée DEC-NNN dans DECISIONS.md (§18.7) ; src/content/fr/legal.ts → mediator P3 Aucun garde automatique : la confirmation est une condition de la liste §1.4.1 ; en cas de B2C, M-12 devient obligatoire

1.3.3 Gardes automatiques #

Les gardes sont numérotés de G-00 à G-11. Ils sont implémentés à trois endroits :

  • src/lib/content/guards.ts (avec les schémas de src/lib/content/schemas.ts) : gardes de contenu G-00, G-01, G-02 (références de contenu), G-03, G-05 et G-11, exécutés au build ;
  • scripts/check-assets.ts : gardes d'actifs G-02 (références d'images), G-07, G-08 et G-09, exécutés en tête de pnpm build ;
  • src/lib/env.ts : gardes d'environnement G-04, G-06 et G-10, exécutés au démarrage du serveur (G-04) et au build (G-06, G-10).

Les gardes qui lisent le système de fichiers (registre content-validation.json, images, licences, logo) ne s'exécutent qu'au build (script de contrôle ou phase de build de Next.js) ; ils ne s'exécutent jamais au démarrage ni pendant la vie du conteneur, qui ne contient pas ces fichiers.

Tous les gardes affichent un message en français, lisible, qui liste chaque élément manquant et le fichier à mettre à jour. Exemple de sortie attendue pour G-01 :

✖ Garde G-01 — informations légales incomplètes (APP_ENV=production)
  Fichier à mettre à jour : src/content/fr/legal.ts
  Champs manquants :
    - publisher.siret
    - publisher.address
    - host.name
    - vatMention
    - privacy.retention
Le build est interrompu. Corrigez les champs ci-dessus puis relancez le build.
ID Condition de blocage Moment Environnements bloquants Mise en œuvre
G-00 Un fichier de src/content/fr/ ne respecte pas son schéma Zod, ou une règle croisée X-01 à X-13 de §9.3.5 est violée Build Tous src/lib/content/guards.ts et src/lib/content/schemas.ts (§9.3)
G-01 Un champ obligatoire de legal.ts est vide (dont privacy.retention), ne respecte pas son format (SIRET : 14 chiffres + Luhn ; e-mail ; URL du médiateur HTTPS) ou lastUpdated est absente ou future Build production src/lib/content/guards.ts (raffinements Zod de src/lib/content/schemas.ts)
G-02 Une référence d'image ou de contenu contient dev-fallback Build production src/lib/content/guards.ts (contenu) et scripts/check-assets.ts (références d'images)
G-03 Un témoignage published: true n'a pas consent.status: 'granted', consent.grantedOn valide ou une quote non vide de 700 caractères au plus Build Tous (erreur de schéma) Raffinement Zod (§9.3)
G-04 Variable d'exécution requise absente ou invalide (BREVO_API_KEY, EMAIL_FROM_ADDRESS, LOG_SALT, etc.) ; EMAIL_DRY_RUN=true en production ; domaine d'expédition appartenant à une messagerie grand public Démarrage du serveur production Validation Zod dans src/lib/env.ts : le processus s'arrête avec un message clair, le contrôle de santé échoue (§13)
G-05 Une clé du registre de validation est absente, sans validatedBy ou sans validatedOn valide (≤ aujourd'hui) Build production (avertissement en development et staging) src/lib/content/guards.ts lit content-validation.json au build uniquement
G-06 SITE_URL n'est pas en HTTPS, désigne localhost, une adresse IP, ou possède une barre finale Build production src/lib/env.ts
G-07 Un fichier d'image référencé est absent, n'a pas les dimensions minimales ni le ratio exigés, ou n'a pas le canal alpha exigé (portrait, calques du hero) Build production scripts/check-assets.ts (lecture des métadonnées avec sharp)
G-08 Un visuel réel n'a pas d'entrée dans docs/assets-licences.json avec commercialWebUse: true (ou usageAuthorisedOn pour les portraits) Build production scripts/check-assets.ts
G-09 Le logo SVG fourni contient <script>, un attribut d'événement on*, une référence externe ou n'a pas de <title> Build Tous scripts/check-assets.ts
G-10 ANALYTICS_PROVIDER=umami sans UMAMI_SCRIPT_URL HTTPS valide ou sans UMAMI_WEBSITE_ID au format UUID Build Tous src/lib/env.ts
G-11 offers.ts ne contient aucune offre Build Tous (erreur de schéma) Schéma Zod (§9.3)

Le registre de validation content-validation.json a la structure suivante. Seule Malka (ou son mandataire explicite) renseigne validatedBy et validatedOn; l'exécutant ne les remplit jamais.

{
  "biography":     { "validatedBy": "", "validatedOn": "" },
  "copyDeck":      { "validatedBy": "", "validatedOn": "" },
  "offers":        { "validatedBy": "", "validatedOn": "" },
  "orientation":   { "validatedBy": "", "validatedOn": "" },
  "altTexts":      { "validatedBy": "", "validatedOn": "" },
  "trustFigures":  { "validatedBy": "", "validatedOn": "" },
  "ogImage":       { "validatedBy": "", "validatedOn": "" }
}

Règle de lecture de G-05 : une clé est « validée » si validatedBy est une chaîne non vide et validatedOn est une date ISO YYYY-MM-DD valide, non future. Le fichier est livré avec toutes les valeurs vides, ce qui rend la production impossible tant que Malka n'a pas validé. La clé copyDeck couvre l'ensemble des textes proposés à Malka (détail des offres, textes de la méthode et de la boussole d'orientation, posture et approche de la section À propos, messages du site) ; la biographie dispose de sa propre clé biography et est la seule source de vérité de sa validation.

1.4 Liste de contrôle avant mise en production #

Cocher chaque ligne dans l'ordre. Une case non cochée interdit la mise en production.

1.4.1 Contenu et mentions légales #

  • src/content/fr/legal.ts est complet : éditeur, hébergeur, mention de TVA, durée de conservation des demandes (privacy.retention), date de dernière mise à jour (M-09 à M-12) ; aucun bandeau de données manquantes ne s'affiche sur les pages légales.
  • content-validation.json est entièrement renseigné par Malka (M-14, M-15, M-21, D-37) ; G-05 passe.
  • Chaque offre a details renseigné ; format vaut « À distance » ; aucune duration n'est présente sans avoir été fournie par Malka.
  • Les témoignages publiés ont un texte mot pour mot, un consentement écrit daté et un canal ; ou aucun témoignage n'est publié et la section est absente de la page et de la navigation.
  • La biographie de la section À propos est celle validée par Malka ; aucune certification ni chiffre non fourni.
  • La politique de confidentialité décrit exactement les traitements réels : formulaire, accusé de réception, e-mail via Brevo, journaux serveur, WhatsApp (service édité par Meta), boussole d'orientation (aucune donnée transmise), cookies et stockage, mesure d'audience seulement si activée.
  • Malka a confirmé par écrit si ses prestations s'adressent à des professionnels ou aussi à des consommateurs (M-22) ; dans le second cas, le médiateur est renseigné (M-12) et les conditions générales sont prévues comme évolution.

1.4.2 Visuels et licences #

  • Aucune référence à dev-fallback dans le build de production (G-02).
  • Portraits et paysage réels intégrés (public/images/hero/ et public/images/about/), détourage propre, aucun visage généré ou retouché par IA (M-01, M-02, M-04).
  • docs/assets-licences.json contient une entrée valide pour chaque visuel réel (G-08).
  • Image Open Graph public/og/og-default.jpg en 1 200 × 630, validée par Malka (M-07).
  • Les fichiers maîtres respectent dimensions minimales, ratios et canal alpha exigés (G-07).

1.4.3 E-mail #

  • Clé BREVO_API_KEY stockée dans l'environnement du serveur, absente du dépôt et des journaux.
  • Domaine d'envoi authentifié dans Brevo ; SPF, DKIM et DMARC publiés (M-17).
  • Contrôle DNS effectué : dig +short TXT <domaine> montre l'enregistrement SPF ; dig +short TXT _dmarc.<domaine> montre l'enregistrement DMARC ; l'enregistrement DKIM fourni par Brevo est présent.
  • EMAIL_DRY_RUN=false en production (la valeur true y est refusée au démarrage, G-04).
  • Envoi réel de bout en bout depuis le formulaire public : la notification arrive chez astheoris.pro@gmail.com (boîte de réception, pas les indésirables), replyTo = e-mail du visiteur ; l'accusé de réception arrive chez le visiteur ; l'en-tête d'authentification indique spf=pass, dkim=pass, dmarc=pass.
  • Test d'échec simulé : sur la préproduction, avec EMAIL_DRY_RUN=false et une clé invalide, le formulaire affiche l'alternative WhatsApp et e-mail (D-32) et le texte saisi reste en place.

1.4.4 Domaine, DNS, HTTPS et hébergement #

  • Le domaine pointe vers le VPS ; www redirige en 301 vers l'apex (D-30).
  • SITE_URL est l'URL HTTPS définitive (G-06) ; le certificat est valide et se renouvelle automatiquement.
  • docker compose ps montre web à l'état healthy ; curl -fsS https://<domaine>/api/health renvoie 200.
  • Le redémarrage automatique est vérifié (arrêt forcé du conteneur → relance en moins de 30 s).
  • Les journaux sont lisibles (docker compose logs web) et ne contiennent aucune donnée personnelle (voir §9.7).
  • La procédure de retour arrière a été jouée au moins une fois sur préproduction (§14).

1.4.5 Qualité #

  • lint, typecheck, tests unitaires, de composants, d'intégration et E2E (accessibilité incluse) passent sur l'image de production (§15).
  • Lighthouse mobile sur le build de production : Performance ≥ 90, Accessibilité 100, Bonnes pratiques ≥ 95, SEO 100 (§11).
  • Aucune violation axe de niveau sérieux ou critique sur /, /mentions-legales, /politique-de-confidentialite, la 404.
  • Parcours au clavier seul et au lecteur d'écran validés (§4.3, J5 et J6).
  • Comportement avec prefers-reduced-motion: reduce validé (§4.3, J7).
  • Aucun défilement horizontal à 320 px ; aucun chevauchement sur la matrice de largeurs de §4.6.
  • En-têtes de sécurité et politique de sécurité du contenu vérifiés (§10).
  • robots.txt autorise l'indexation et sitemap.xml liste les trois URL canoniques ; aucune balise noindex ni en-tête X-Robots-Tag en production (D-26).

1.4.6 Analytique (uniquement si activée) #

  • Instance Umami joignable en HTTPS ; data-do-not-track="true", data-exclude-search="true" et data-exclude-hash="true" présents ; aucun cookie déposé et stockage du navigateur vide tant que le visiteur n'a pas cliqué sur le bouton d'opposition (D-03).
  • La section « mesure d'audience » de la politique de confidentialité s'affiche ; elle est absente si l'analytique est désactivée.
  • Aucun événement n'envoie de donnée personnelle, d'identifiant de situation de la boussole ni d'offre présélectionnée ; aucune adresse mesurée ne porte de paramètre d'orientation (voir §9.8).

1.4.7 Après la mise en ligne #

  • Soumettre https://<domaine>/sitemap.xml à l'outil de recherche de l'éditeur de moteur de son choix (facultatif, recommandé).
  • Noter dans README.md la version déployée (APP_VERSION) et la date de mise en production.
  • Conserver les trois dernières images (malka-site:<APP_VERSION>) pour le retour arrière.

2. Vision produit, objectifs et publics cibles #

2.1 Vision #

Faire de la visite du site de Malka Debleds une expérience de découverte, de clarification, puis de contact. Le site n'est ni un gabarit, ni une maquette : c'est un site de production, sobre, élégant, humain, accessible et commercialement efficace, d'un niveau d'exécution comparable aux studios numériques haut de gamme, sans effet gratuit ni surcharge graphique.

Le site présente Malka Debleds comme « professionnelle de l'accompagnement vibratoire et stratégique des entrepreneurs ». Il explique son approche (« Entreprise vivante » : une entreprise n'est pas séparée de la personne qui la porte), présente ses offres sans détour et conduit le visiteur vers une prise de contact simple.

2.2 Ambition #

Dimension Ambition Traduction produit
Émotion Apaiser, éclaircir, inspirer confiance dès les premières secondes Hero cinématographique « CLARTÉ » : paysage de montagne au coucher du soleil, portrait réel de Malka, lumière chaude
Compréhension Faire comprendre l'approche en moins de deux minutes de lecture Philosophie éditoriale courte, boussole de méthode en quatre phases
Décision Permettre de se situer et de choisir une offre sans pression Offres lisibles avec prix, boussole d'orientation sans diagnostic
Action Rendre le contact évident, rapide et fiable Un point de conversion unique (§Contact), WhatsApp en premier, formulaire fonctionnel
Exigence Premium sans sacrifier l'accessibilité, la vitesse ni la conversion Cibles de performance et d'accessibilité mesurables (§11)
Autonomie Laisser Malka maîtriser ses offres, prix et témoignages Contenu en fichiers de données typés et validés au build (§9.3)

2.3 Proposition de valeur #

Pour le visiteur : « Une écoute profonde, une lecture claire et des actions concrètes pour avancer avec plus de sens, de cohérence et de justesse », portée par une personne réelle, identifiable, joignable en un geste (WhatsApp ou formulaire).

Les trois promesses du site (promesses de service, jamais de résultat) :

  1. Une écoute — un premier échange humain, sans engagement implicite.
  2. Voir plus clair — une méthode simple en quatre phases (Écouter, Explorer, Clarifier, Décider) et des offres dont le prix et le périmètre sont visibles.
  3. Pouvoir avancer — des actions concrètes à la suite de la lecture, avec un accompagnement à distance partout en France.

2.4 Positionnement et garde-fous éditoriaux #

Positionnement : accompagnement vibratoire et stratégique des entrepreneurs, à distance, en France. Cette formule est celle de Malka ; elle est employée telle quelle comme descripteur de sa pratique.

Garde-fous obligatoires (juridiques et éthiques), applicables à tout texte, visuel et message du site :

Règle Interdit Autorisé
Pas de revendication thérapeutique ou de santé « guérir », « soigner », « thérapie », « traitement », « diagnostic », « burn-out résolu », toute allégation médicale ou psychologique « accompagnement », « écoute », « lecture », « clarifier », « piste de réflexion »
Pas de promesse de résultat « résultats garantis », « doublez votre chiffre d'affaires », « 100 % de réussite », tout chiffre de performance « avancer avec plus de sens, de cohérence et de justesse », « actions concrètes »
Pas d'efficacité alléguée du « vibratoire » Affirmer un effet physique, énergétique ou scientifique mesurable Employer « vibratoire » comme descripteur de la pratique de Malka, sans en démontrer ni en promettre d'effet
Pas de statistiques inventées Tout chiffre autre que 300+ personnes accompagnées et 50+ personnes formées Ces deux chiffres, confirmés par Malka (D-37)
Pas de fausse preuve sociale Faux avis, faux clients, visages générés, logos de clients non fournis Uniquement les témoignages réels, vérifiés, consentis (§6.8)
Pas de pression commerciale Compte à rebours, rareté artificielle, formulations culpabilisantes Invitation claire et calme à échanger
Pas de promesse de délai de réponse « sous 24 h », « rapidement », « dans les meilleurs délais », tout engagement de délai Indiquer seulement que la demande a bien été transmise à Malka
Pas de diagnostic automatique Conclusion sur l'état de la personne La boussole d'orientation propose une piste de réflexion, rappelle qu'elle « ne constitue ni un diagnostic ni une promesse de résultat » (§6.6)
Pas de certification ou de diplôme non fourni Titres, labels, affiliations Uniquement ce que Malka fournit et valide (M-14)

2.5 Objectifs business et indicateurs de succès #

Modèle économique : génération de demandes uniquement (pas de paiement en ligne). Les séances payantes (150 €, 300 €, 1 000 € ou sur devis) se concluent hors site après un échange par WhatsApp ou par e-mail.

2.5.1 Objectifs #

ID Objectif Résultat attendu
O-1 Transformer la visite en demande réelle Le visiteur qui le souhaite contacte Malka en moins de trois interactions depuis n'importe quelle section
O-2 Établir la confiance Identité, visage, posture et offres sont transparents ; aucune preuve sociale non vérifiable
O-3 Qualifier la demande Le message reçu par Malka précise l'offre ou la situation du visiteur lorsqu'il l'a choisie
O-4 Garantir une expérience irréprochable Performance, accessibilité et fiabilité conformes à §11 et à §3.3
O-5 Laisser Malka autonome Modification des offres, prix, témoignages et chiffres sans toucher aux composants

2.5.2 Indicateurs de succès #

Les cibles ci-dessous sont des objectifs de pilotage initiaux, révisables après 90 jours de données réelles ; elles sont exprimées en ratios, jamais en volumes de trafic.

ID Indicateur Définition Source de mesure Disponible sans analytique ? Cible initiale
K-01 Demandes par formulaire Nombre de soumissions acceptées (hors spam) Journaux serveur (voir §9.7) ; événement contact_submit avec result: success si l'analytique est activée Oui (journaux) Suivi mensuel ; aucun seuil de volume imposé
K-02 Fiabilité d'envoi Soumissions valides distribuées ÷ soumissions valides reçues Journaux serveur (sorties d'envoi réussi et EMAIL_DELIVERY_FAILED) Oui ≥ 99 %
K-03 Clics WhatsApp Événements cta_click pour offer_whatsapp et contact_whatsapp Analytique Non : mesurable uniquement si ANALYTICS_PROVIDER=umami. L'ouverture de la conversation WhatsApp, elle, n'est jamais observable Suivi mensuel ; aucun seuil imposé
K-04 Intention de contact Événements cta_click (tous CTA menant au contact ou à WhatsApp) ÷ pages vues Analytique Non ≥ 4 % (hypothèse de départ, à recalibrer après 90 jours)
K-05 Taux d'erreur d'envoi côté visiteur Événements contact_submit avec result: error ÷ total des événements contact_submit Analytique Non ≤ 2 % (hypothèse de départ, à recalibrer après 90 jours)
K-06 Part de spam abandonné Soumissions spam_dropped ÷ soumissions reçues Journaux serveur Oui Information ; alerte si > 50 % sur 7 jours (changer les protections, voir §9.6)
K-07 Engagement méthode Événements method_step_open Analytique Non Information, aucun seuil
K-08 Performance (laboratoire) Lighthouse mobile : Performance ≥ 90, Accessibilité 100, Bonnes pratiques ≥ 95, SEO 100 Lighthouse CI (§11, §15) Oui Valeurs de §11
K-09 Core Web Vitals LCP ≤ 2,5 s, CLS ≤ 0,05, INP ≤ 200 ms Laboratoire et, si disponible, mesures terrain (§11) Oui (laboratoire) Valeurs de §11
K-10 Disponibilité Part du temps où /api/health répond 200 Surveillance externe de l'exploitant ou journaux (§14) Oui ≥ 99,5 % par mois (hypothèse de départ, à recalibrer après 90 jours)

Règles de mesure :

  • Quand l'analytique est désactivée (défaut), K-03, K-04, K-05 et K-07 ne sont pas mesurables ; le site n'ajoute aucun traceur pour les compenser.
  • Aucun indicateur n'utilise de donnée personnelle ; la situation choisie dans la boussole n'est jamais mesurée (voir §9.8).
  • Les valeurs des cibles de performance sont celles de §11 ; elles sont reprises ici pour lisibilité.

2.6 Publics cibles (personas) #

Quatre personas décrivent les usages réels. Les trois premiers sont des visiteurs ; le quatrième est la propriétaire du site. Aucun n'est un client réel.

2.6.1 Persona 1 — La personne qui dirige son entreprise, bloquée ou fatiguée #

Aspect Description
Profil Dirige une petite entreprise ou une activité indépendante ; se sent bloquée face à une décision, ou épuisée, sans savoir par où commencer. Consulte souvent le site le soir, sur mobile.
Besoins Se sentir comprise ; comprendre si cet accompagnement lui convient ; un premier pas peu engageant.
Peurs et freins Être jugée ; payer cher une démarche floue ; se voir promettre des miracles ; perdre du temps.
Ce qui la convainc Un ton calme et sincère ; un visage réel ; des offres claires avec le prix ; la possibilité d'écrire sur WhatsApp sans formulaire long ; une boussole qui propose une piste sans étiqueter.
Parcours principal Hero → Philosophie → Boussole d'orientation (« décision bloquée » ou « fatigue ») → offre suggérée → contact (voir §4.3, J3).
Parcours secondaire Hero → CTA « Prendre rendez-vous » → WhatsApp (J1, J4).
Contexte technique Mobile (iOS Safari ou Android Chrome), connexion variable, mouvement parfois réduit.

2.6.2 Persona 2 — L'indépendant ou l'indépendante en repositionnement ou en transition #

Aspect Description
Profil Personne installée dans son activité qui envisage de changer d'offre, de cible ou d'activité. Compare plusieurs accompagnants, lit avec attention.
Besoins Comprendre la méthode et la durée d'engagement avant de contacter ; évaluer la cohérence entre la personne et la méthode.
Peurs et freins Un discours vague ; une méthode non expliquée ; un engagement de plusieurs mois sans visibilité.
Ce qui la ou le convainc La boussole de clarté (quatre phases) ; le détail des offres, notamment le suivi de transition sur six mois ; la section À propos transparente ; des témoignages réels vérifiés (s'ils sont publiés).
Parcours principal Hero → Méthode (étapes dépliées) → Accompagnements → À propos → Contact (J1, J2).
Parcours secondaire Boussole d'orientation (« repositionnement » ou « transition ») → offre suggérée.
Contexte technique Ordinateur portable ou tablette, clavier et souris ; utilise parfois le clavier seul.

2.6.3 Persona 3 — La future cliente ou le futur client, prêt à réserver #

Aspect Description
Profil A déjà été orientée ou orienté vers Malka (bouche-à-oreille, réseau) ; sait ce qu'elle ou il cherche et veut agir rapidement.
Besoins Trouver le contact en quelques secondes ; savoir quelle offre correspond ; recevoir une confirmation.
Peurs et freins Un formulaire interminable ; ne pas savoir si le message est parti ; un bouton qui ne fonctionne pas.
Ce qui la ou le convainc WhatsApp en un tap, message prérempli avec l'offre ; un formulaire court avec confirmation claire ; téléphone et e-mail visibles.
Parcours principal Offre → WhatsApp prérempli (J2) ; ou mobile → Contact → WhatsApp (J4).
Parcours secondaire Lien « Écrire via le formulaire » d'une offre, qui présélectionne l'offre dans le formulaire ; repli WhatsApp, e-mail ou téléphone en cas d'échec (J9).
Contexte technique Mobile prioritairement ; peut utiliser un lecteur d'écran, le zoom du texte ou la navigation vocale.

2.6.4 Persona 4 — Malka, propriétaire du site #

Aspect Description
Profil Praticienne, non développeuse ; veut que le site reflète sa pratique et soit simple à tenir à jour. Elle (ou un mandataire) modifie des fichiers de données, pas les composants.
Besoins Modifier un prix, une offre, un témoignage, un chiffre sans casser le site ; être sûre que rien d'inventé ou de non validé n'est publié ; recevoir chaque demande de façon fiable.
Peurs et freins Publier une erreur légale ; un témoignage non autorisé ; un e-mail qui n'arrive pas ; dépendre d'un prestataire pour chaque virgule.
Ce qui la convainc Fichiers de contenu typés avec messages d'erreur lisibles ; gardes de build qui bloquent la production quand il manque une information ; notifications d'e-mail qui répondent au visiteur d'un clic.
Parcours principal Modifier offers.ts ou testimonials.ts → le build valide ou affiche un message clair → déploiement reproductible (voir §9.3 et §14).
Parcours secondaire Recevoir une demande → répondre directement (replyTo = visiteur) ou continuer sur WhatsApp.
Contexte technique Ordinateur ; interface du dépôt ou éditeur de texte ; le détail de la procédure figure dans README.md (rubriques de §18.9.1).

2.7 Hors périmètre (non-objectifs) #

Les éléments suivants sont explicitement hors périmètre au lancement. L'exécutant ne les implémente pas, et l'architecture n'a pas à les anticiper au-delà des points d'extension indiqués.

Hors périmètre Précision
Paiement en ligne Aucun paiement, aucun panier ; les paiements se font hors site
Calendrier de réservation en ligne Prévu plus tard via Cal.com ; seul le point d'extension de §9 existe
Base de données, stockage des messages Les demandes ne sont pas enregistrées ; seul l'e-mail envoyé les contient
Back-office, CMS, interface d'administration Le contenu se modifie dans des fichiers de données typés
Contenu multilingue Français seul ; architecture prête à l'extension (D-01)
Blog, newsletter, espace client Aucune page ni collecte d'adresses à ces fins
Cookies de suivi ou publicitaires, pixels, remarketing Interdits ; l'analytique éventuelle est sans cookie
Visages générés par IA, faux avis, statistiques inventées Interdits (§1.1.5, §2.4)
Infrastructure complexe Pas de Kubernetes, pas de base distribuée, pas de service géré en plus de Brevo
Thème sombre sélectionnable Un seul thème (D-22)
Chat en direct, assistant IA, chatbot Non demandés ; la boussole d'orientation est un module sans appel réseau, sans stockage ni envoi de donnée
Inscription, connexion, comptes visiteurs Aucun compte
Page de conditions générales de vente Hors lancement ; devient une évolution si Malka confirme vendre à des consommateurs (M-22)

2.8 Principes de ton de marque #

Le détail des textes est dans §7 ; ces principes servent à les rédiger, à les relire et à les refuser.

Principe Signification À faire À éviter
Sobre Dire peu, dire juste Phrases courtes, titres éditoriaux, espace blanc généreux Superlatifs, exclamations, jargon marketing
Humain La personne avant la méthode Malka nommée et identifiable ; le site parle de « Malka » à la troisième personne (la première personne n'est utilisée que dans les messages WhatsApp préremplis, écrits par le visiteur) Ton impersonnel d'agence, formules creuses, « nous »
Clair Comprendre du premier coup Verbes d'action, vocabulaire simple, prix visibles Abstractions, acronymes, promesses floues
Juste Ne rien promettre qui ne soit tenu Formulations conditionnelles (« peut aider à », « pour avancer ») ; mises en garde de la boussole Garanties, comparaisons avec d'autres praticiens, urgence artificielle
Vivant Un ton chaleureux, organique Images de la nature (montagne, vallée, rivière, lumière) cohérentes avec « Entreprise vivante » Métaphores guerrières ou techniques, vocabulaire froid
Respectueux Vouvoiement, jamais de culpabilisation « Vous » ; invitation (« Faire le point ») plutôt qu'injonction ; formulations épicènes ou doublets explicites, sans point médian (D-33) Tutoiement, « Ne ratez pas… », « Dernière chance »
Accessible Lisible par tous Texte alternatif utile, libellés explicites des liens et boutons, français courant Texte uniquement dans une image, liens « cliquez ici »

Mots du lexique de marque, à employer avec leur graphie exacte : « Malka Debleds », « Entreprise vivante », « Boussole de clarté », « Passage & Clarté », « CLARTÉ ».

3. Exigences fonctionnelles et non fonctionnelles #

3.1 Lecture des tableaux #

  • Chaque exigence a un identifiant stable (FR-xx, NFR-xx), une priorité (MVP = au lancement ; Évolution = prévu par l'architecture, non livré), une section propriétaire (où elle est spécifiée en détail) et un critère d'acceptation AC-3.2-nn ou AC-3.3-nn vérifiable.
  • Les critères d'acceptation de ce tableau sont des critères de recette fonctionnelle de haut niveau. Chaque section propriétaire porte ses propres critères détaillés (AC-6.x-nn, etc.) ; la matrice de tests est dans §15.
  • Les lettres A à J désignent les dix blocs fonctionnels de la page : A navigation, B hero, C philosophie, D méthode, E accompagnements, F boussole d'orientation, G À propos, H témoignages, I contact, J footer. La correspondance complète figure en §3.6.

3.2 Exigences fonctionnelles #

3.2.1 A — Navigation #

ID Exigence Priorité Section Critère d'acceptation
FR-01 Un header fixe discret (72 px à partir de 1024 px, 64 px en dessous), toujours visible pendant tout le défilement (aucun masquage à la descente) ; trois états : top (transparent sur le hero), scrolled (fond vert forêt translucide après 24 px de défilement, retour sous 8 px) et solid (pages légales, fond plein dès le chargement) ; fond d'opacité 0,92 sous 768 px, 0,85 avec flou d'arrière-plan de 12 px à partir de 768 px (repli à 0,97 sans backdrop-filter) MVP §6.1 AC-3.2-01 : à toute position de défilement, le header est visible, sa hauteur est celle indiquée, et aucun élément focalisé n'est masqué par lui
FR-02 Liens d'ancre dans l'ordre : Philosophie · Méthode · Accompagnements · Orientation · À propos · Témoignages (conditionnel) · Contact ; après une navigation par ancre, le focus est placé sur la racine de la section ciblée MVP §4.2, §6.1 AC-3.2-02 : l'ordre et les libellés correspondent exactement à §4.2 ; chaque lien déplace la vue sur la section correspondante, titre visible sous le header, et la section reçoit le focus
FR-03 Le lien de la section visible est mis en évidence (aria-current="location") MVP §6.1 AC-3.2-03 : en faisant défiler /, un seul lien est actif à la fois et il change lorsque la section visible change ; aucun lien actif sur les pages légales
FR-04 Menu mobile accessible en dessous de 1280 px : bouton à état (aria-expanded, aria-controls="menu-mobile"), panneau plein écran role="dialog" aria-modal="true" avec sa propre barre (logotype et bouton de fermeture), piège de focus, fermeture par Échap (sans fermeture au clic sur le fond), retour du focus sur le bouton, blocage du défilement de fond ; animation en fondu avec léger déplacement vertical, instantanée en mouvement réduit ; les liens inline apparaissent à partir de 1280 px MVP §6.1 AC-3.2-04 : au clavier et au toucher, le menu s'ouvre et se ferme ; Échap ferme et rend le focus au bouton ; chaque lien et le CTA ont une cible ≥ 44 × 44 px ; le panneau se ferme si la fenêtre atteint 1280 px
FR-05 CTA « Faire le point » dans le header, cible #contact, visible dans la barre dès 640 px MVP §6.1 AC-3.2-05 : le clic mène à #contact ; l'événement cta_click (nav_faire_le_point) n'est émis que si l'analytique est activée
FR-06 Lien d'évitement « Aller au contenu » : premier élément focalisable de chaque page, cible #contenu MVP §4.2, §6.1 AC-3.2-06 : au premier appui sur Tab, le lien est visible et focalisé ; Entrée place le focus au début du contenu principal
FR-07 Sur les pages légales, le logo mène à / et les autres liens pointent vers /#<ancre> MVP §4.2, §6.1 AC-3.2-07 : depuis /mentions-legales, chaque lien de navigation ramène à la section correspondante de /
FR-08 Le lien « Témoignages » n'existe ni dans le header, ni dans le menu mobile, ni dans le footer quand aucun témoignage n'est publiable MVP §6.8 AC-3.2-08 : avec les données livrées par défaut, aucun lien ni section « Témoignages » n'existe dans le HTML

3.2.2 B — Hero « CLARTÉ » #

ID Exigence Priorité Section Critère d'acceptation
FR-09 Composition « Hero 02 — Clarté » à partir de 1024 px : vert forêt à gauche avec la colonne de texte, portrait de Malka à droite (grille à deux colonnes 7/5), mot géant semi-transparent CLARTÉ derrière le portrait, paysage panoramique multicouche, transition douce vers la section suivante ; rail vertical de méthode dans la gouttière droite à partir de 1280 px, remplacé de 1024 à 1279 px par une rangée horizontale de quatre étapes sous les chiffres de confiance MVP §4.4.2, §6.2 AC-3.2-09 : à 1440 × 900, les zones texte, portrait et rail ne se recouvrent pas (bord droit du bloc titre + 24 px ≤ bord gauche du portrait) ; le mot CLARTÉ est derrière le portrait ; la lecture du texte atteint ≥ 4,5:1
FR-10 Titre h1 unique de la page : « Retrouver la clarté pour faire grandir une entreprise alignée. » (trois lignes fixes à partir de 1024 px) ; description : « Une écoute profonde, une lecture claire et des actions concrètes pour avancer avec plus de sens, de cohérence et de justesse. » MVP §6.2, §7 AC-3.2-10 : la page contient exactement un h1 ; le texte est présent dans le HTML initial (sans JavaScript)
FR-11 Deux CTA : principal « Prendre rendez-vous » → #contact ; secondaire « Découvrir la méthode » → #methode MVP §6.2 AC-3.2-11 : les deux liens existent, ont les cibles indiquées et une cible tactile ≥ 44 px de haut
FR-12 Chiffres de confiance issus de site.ts : « 300+ personnes accompagnées », « 50+ personnes formées » MVP §6.2, §9.3 AC-3.2-12 : modifier site.ts modifie l'affichage après build ; les chiffres ne sont définis qu'à un seul endroit
FR-13 Rail de méthode : quatre éléments (Écouter, Explorer, Clarifier, Décider) aux libellés horizontaux, liens vers #methode-ecouter, #methode-explorer, #methode-clarifier, #methode-decider ; vertical dans la gouttière droite à partir de 1280 px (capsules sombres derrière les libellés), horizontal sous les chiffres de confiance en dessous de 1280 px MVP §6.2, §4.4.2 AC-3.2-13 : chaque élément active l'étape correspondante de la boussole ; à 390 px les quatre libellés sont lisibles et non tronqués
FR-14 Recomposition tablette et mobile conforme à D-20 : surtitre, titre, description, CTA, bande portrait encadrée (16:10 de 768 à 1023 px, 9:10 en dessous) avec CLARTÉ réduit, chiffres de confiance, rail horizontal MVP §4.4.2, §6.2 AC-3.2-14 : aux largeurs 320, 390, 834, aucun chevauchement ni défilement horizontal ; le texte n'est jamais superposé à une image
FR-15 Séquence d'entrée orchestrée en ≤ 2,2 s ; le titre est peint dans le HTML initial, ne bouge ni ne s'estompe jamais, et le LCP n'est jamais retardé par l'animation MVP §8 AC-3.2-15 : LCP laboratoire ≤ 2,5 s ; l'élément LCP n'a jamais opacity: 0 ; avec JavaScript désactivé, le titre est visible

3.2.3 C et D — Philosophie et méthode #

ID Exigence Priorité Section Critère d'acceptation
FR-16 Section « Entreprise vivante » éditoriale : une entreprise n'est pas séparée de la personne qui la porte ; grande typographie ; animations subtiles MVP §6.3, §7 AC-3.2-16 : la section #philosophie existe avec un h2, le texte validé de §7, et ses révélations sont désactivées en mouvement réduit
FR-17 Boussole de clarté : quatre phases (Écouter, Explorer, Clarifier, Décider) ; explication au clic, au toucher, au clavier (flèches avec activation automatique de l'onglet) et au survol sur pointeur fin MVP §6.4 AC-3.2-17 : chaque phase affiche son explication avec les quatre méthodes d'entrée ; une seule explication est visible à la fois (les quatre panneaux restent dans le DOM, les inactifs masqués)
FR-18 Liens profonds #methode-ecouter … #methode-decider activent l'étape (écoute de hashchange) MVP §4.2, §6.4 AC-3.2-18 : charger /#methode-clarifier affiche la section méthode avec « Clarifier » active ; modifier le hash l'active sans rechargement
FR-19 Boussole entièrement exploitable au clavier et au toucher ; états exposés par ARIA ; sans JavaScript, les quatre explications sont lisibles en liste MVP §6.4 AC-3.2-19 : parcours Tab / flèches sans souris ; avec JavaScript désactivé, les 4 textes sont visibles
FR-20 Événement method_step_open à l'ouverture d'une étape (analytique activée seulement) MVP §9.8 AC-3.2-20 : avec ANALYTICS_PROVIDER=none, aucun appel réseau ; avec umami, un événement par ouverture et aucune donnée personnelle

3.2.4 E — Accompagnements #

ID Exigence Priorité Section Critère d'acceptation
FR-21 Quatre offres issues de offers.ts : Séance de clarification ciblée 300 €, Lecture globale de l'entrepreneur et de l'entreprise 150 €, Suivi de transition sur six mois 1 000 €, Accompagnement personnalisé sur devis MVP §6.5, §9.3 AC-3.2-21 : modifier un prix, un titre ou l'ordre dans offers.ts modifie la page après build, sans toucher à un composant ; le build échoue si une offre est invalide
FR-22 Chaque offre a un CTA WhatsApp « Écrire sur WhatsApp » au message prérempli propre à l'offre (modèle par défaut : « Bonjour Malka, je souhaite en savoir plus sur « {title} ». Pouvons-nous échanger ? ») MVP §6.5, §9.3 AC-3.2-22 : le lien est https://wa.me/33667295179?text=… correctement encodé, s'ouvre dans un nouvel onglet avec rel="noopener noreferrer" et un texte masqué annonçant l'ouverture de WhatsApp
FR-23 Libellé « Tarif » visible ; prix au format fr-FR produit par Intl en euros sans décimale ; « Sur devis » pour l'offre personnalisée ; mention de TVA lue dans legal.ts affichée sous la liste des offres MVP §6.5, §6.11 AC-3.2-23 : l'offre à 1 000 € s'affiche telle que formatée par Intl.NumberFormat('fr-FR') (espaces insécables) ; la mention de TVA affichée est celle fournie, jamais une valeur par défaut
FR-24 Une offre peut être mise en avant (featured), configurable (panneau sombre, les autres offres en rangées éditoriales) ; l'ordre de lecture reste l'ordre de order MVP §6.5 AC-3.2-24 : changer featured déplace la mise en avant visuelle sans changer l'ordre du DOM
FR-25 CTA secondaire par offre « Écrire via le formulaire » : lien href="/?offre=<id>#contact" (fonctionne sans JavaScript) qui présélectionne l'offre dans le formulaire de contact ; au chargement dur, le formulaire lit ?offre une seule fois puis l'adresse est nettoyée MVP §6.5, §6.9 AC-3.2-25 : le clic fait défiler jusqu'à #contact et le champ d'offre vaut l'offre choisie ; événement cta_click avec cta: 'offer_form' si l'analytique est activée

3.2.5 F — Boussole d'orientation #

ID Exigence Priorité Section Critère d'acceptation
FR-26 Le visiteur choisit une situation (décision bloquée, fatigue, repositionnement, transition) et reçoit une piste de réflexion, 2 à 3 questions et une offre éventuellement adaptée (mappage D-11) MVP §6.6, §9.3 AC-3.2-26 : les quatre situations produisent les offres suggérées de D-11 ; la modification du mappage dans orientation.ts suffit à le changer
FR-27 Aucun diagnostic, aucune promesse de résultat ; mise en garde globale : « Cette boussole propose une piste de réflexion. Elle ne constitue ni un diagnostic ni une promesse de résultat. » ; état uniquement côté client, rien n'est stocké ni envoyé MVP §6.6, §10 AC-3.2-27 : la mise en garde est visible avec chaque résultat ; aucune requête réseau, aucun stockage navigateur lors d'une sélection
FR-28 Le panneau de résultat de la boussole propose deux CTA : « Écrire sur WhatsApp » et « Écrire via le formulaire » ; ce dernier mène au contact et présélectionne l'offre et la situation (liste facultative « Votre situation ») sans jamais placer la situation dans une adresse MVP §6.6, §6.9 AC-3.2-28 : après le clic, le formulaire contient l'offre suggérée et la situation ; le champ message est vide et n'est jamais prérempli ; aucune adresse, aucun stockage navigateur ne contient la situation

3.2.6 G et H — À propos et témoignages #

ID Exigence Priorité Section Critère d'acceptation
FR-29 Section À propos : photographie réelle de Malka, présentation personnelle, posture professionnelle, approche humaine ; texte validé par Malka MVP §6.7 AC-3.2-29 : en production, la biographie provient de about.ts validée (G-05, clé biography) et la posture et l'approche sont couvertes par la clé copyDeck ; l'image n'est jamais une image de repli (G-02)
FR-30 Slider de témoignages premium issu de testimonials.ts, à partir de deux témoignages publiables : transitions douces, précédent/suivant de 48 px en boucle, indicateurs (zone tactile de 44 px), navigation clavier et tactile, pas de défilement automatique ; une seule citation est affichée seule, sans contrôle ; sans JavaScript toutes les citations sont listées MVP §6.8 AC-3.2-30 : avec ≥ 2 témoignages publiables, précédent/suivant, indicateurs et flèches du clavier fonctionnent ; rien ne défile seul
FR-31 Un témoignage est publiable si et seulement si published est vrai, consent.status vaut granted, consent.grantedOn est une date valide et quote n'est pas vide (700 caractères au plus, sans minimum) MVP §6.8, §9.3 AC-3.2-31 : un témoignage published: true sans consentement ou à citation vide fait échouer le build (G-03)
FR-32 Aucun témoignage publiable → section, lien du header/menu et lien de pied de page absents ; plus aucun espace vide ; l'alternance de fonds se recalcule MVP §4.7, §6.8 AC-3.2-32 : avec les données livrées, #temoignages n'existe pas ; l'ordre des fonds reste alterné
FR-33 Aucune photo, aucun avatar, aucun monogramme pour les auteurs de témoignages : nom et contexte seulement MVP §6.8 AC-3.2-33 : aucun <img> n'est rendu dans un témoignage

3.2.7 I — Contact et réservation #

ID Exigence Priorité Section Critère d'acceptation
FR-34 Bloc de contact avec CTA WhatsApp (06 67 29 51 79) placé avant le formulaire, plus téléphone (tel:) et e-mail (mailto:) MVP §6.9 AC-3.2-34 : les trois liens existent et utilisent le numéro international +33667295179 ; WhatsApp est le premier élément de la section
FR-35 Formulaire (nom, e-mail, téléphone facultatif, offre facultative, situation facultative, message, case d'accusé de lecture de la politique de confidentialité) avec le même schéma Zod côté client et serveur ; le champ message n'est jamais prérempli MVP §6.9, §9.4 AC-3.2-35 : chaque règle de §9.4 est refusée avec un message français sous le champ concerné, côté client et côté serveur
FR-36 Protection contre les abus : champ piège, délai minimal 3 s, validité 24 h, contrôle d'origine, limitation de débit (dont plafond de tentatives et plafonds propres à l'accusé de réception) ; ordre de traitement : méthode, type de contenu, origine, taille, JSON, limitation, anti-spam, validation Zod MVP §9.4, §9.6, §10 AC-3.2-36 : un champ piège rempli ou un envoi en moins de 3 s est abandonné silencieusement (réponse 200, aucun e-mail) ; la 6ᵉ soumission acceptée en 10 min depuis une même IP reçoit 429 avec Retry-After (maximum des règles refusantes)
FR-37 États du formulaire : repos, validation en erreur (récapitulatif d'erreurs qui reçoit le focus, validation au départ du champ seulement s'il a été modifié), envoi en cours (aria-busy, champs en lecture seule, bouton aria-disabled, indicateur ; pas de fieldset disabled), succès, erreur serveur ; délai maximal côté client 30 s ; envoi multiple impossible MVP §6.9 AC-3.2-37 : chaque état est atteignable en test ; l'annonce vocale (aria-live) correspond à l'état ; un double clic n'envoie qu'une requête
FR-38 Notification à Malka (astheoris.pro@gmail.com) avec replyTo = e-mail du visiteur ; tout champ visiteur échappé MVP §9.5 AC-3.2-38 : le message arrive, la réponse va au visiteur ; un message contenant du HTML s'affiche en texte
FR-39 Accusé de réception au visiteur (par défaut activé), texte fixe sans écho du contenu saisi, envoyé après la réponse HTTP ; son échec n'échoue pas la demande MVP §9.5 AC-3.2-39 : l'accusé arrive ; en désactivant le fournisseur pour l'accusé seul, la réponse reste 200 et l'échec est journalisé
FR-40 Échec d'envoi : message d'erreur clair, texte saisi conservé, alternatives WhatsApp et e-mail proposées MVP §6.9 AC-3.2-40 : un EMAIL_DELIVERY_FAILED (502) simulé affiche les alternatives et laisse le message intact
FR-41 Aucune donnée du visiteur n'est stockée par le site au-delà de l'e-mail envoyé MVP §10 AC-3.2-41 : aucun fichier, base ni journal ne contient nom, e-mail, téléphone ou message
ID Exigence Priorité Section Critère d'acceptation
FR-42 Footer « Passage & Clarté » : vert forêt, montagnes et rivière lumineuse entièrement vectorielles (SVG, aucun fichier image), CLARTÉ discret géant, navigation, coordonnées (téléphone, e-mail, « France — accompagnement à distance »), liens légaux MVP §6.10 AC-3.2-42 : les éléments existent ; le mot CLARTÉ est décoratif (aria-hidden) et ne gêne aucun texte
FR-43 Carte CTA « flottante » dans le footer : élevée visuellement, position: relative (jamais fixed ni sticky), chevauchant la jonction Contact → Footer de --footer-overlap (48, 64 ou 80 px selon le point de rupture) avec au moins 64 px d'espace libre réservés au bas de #contact ; boutons « Écrire sur WhatsApp » et « Écrire via le formulaire » MVP §4.5, §6.10 AC-3.2-43 : la carte ne recouvre aucun texte à 320, 390, 834 et 1440 px

3.2.9 Système d'animation #

ID Exigence Priorité Section Critère d'acceptation
FR-44 Système d'animation Motion : variantes avec cascade, révélation des titres ligne par ligne, parallaxe légère multicalque au défilement (à partir de 1024 px, pointeur fin, mouvement non réduit ; aucune parallaxe au pointeur), révélations au défilement, micro-interactions, éléments atmosphériques animés (un seul cycle de 5 s au plus à chaque entrée dans la fenêtre) MVP §8 AC-3.2-44 : seules les propriétés transform, opacity, filter (flou ≤ 8 px sur ordinateur) et clip-path sont animées ; CLS ≤ 0,05
FR-45 prefers-reduced-motion: reduce : aucun déplacement, mise à l'échelle, parallaxe ni boucle ; apparition par fondu ≤ 0,2 s ou instantanée MVP §8 AC-3.2-45 : avec l'émulation activée, aucune transformation animée n'est observée ; le contenu reste complet
FR-46 Effets simplifiés sur mobile et tactile (pas de parallaxe, pas de flou, pas de boucle d'ambiance) MVP §8 AC-3.2-46 : sous 1024 px ou avec (pointer: coarse), aucun calque de parallaxe n'est animé

3.2.10 Pages légales, pages système et SEO #

ID Exigence Priorité Section Critère d'acceptation
FR-47 Pages /mentions-legales et /politique-de-confidentialite générées depuis legal.ts MVP §6.11, §10 AC-3.2-47 : les deux pages existent, sont statiques et lisibles sans JavaScript
FR-48 Garde de production : le build échoue si une information légale requise manque ; en développement et préproduction, un bandeau visible signale les valeurs manquantes MVP §1.3.3, §6.11 AC-3.2-48 : G-01 interrompt le build de production en listant les champs ; en développement le bandeau s'affiche
FR-49 La politique de confidentialité ne décrit que les traitements réels (rubriques définies en §6.11) ; la partie « mesure d'audience » n'apparaît que si l'analytique est activée MVP §10 AC-3.2-49 : avec ANALYTICS_PROVIDER=none, aucune mention d'Umami ; avec umami, la section apparaît
FR-50 Page 404 (not-found) et frontière d'erreur (error) en français, avec retour à l'accueil et contact MVP §6.11 AC-3.2-50 : une URL inconnue renvoie le statut 404 et la page prévue ; une erreur de rendu affiche la frontière sans écran blanc
FR-51 SEO technique : titre et description par page, URL canoniques, Open Graph et Twitter Cards, sitemap.xml (trois URL dans tous les environnements), robots.txt, lang="fr", public/og/og-default.jpg en 1200 × 630, données structurées définies en §11 MVP §11 AC-3.2-51 : Lighthouse SEO = 100 sur le build de production ; les URL canoniques commencent par SITE_URL ; l'image Open Graph se charge
FR-52 Images servies par next/image (AVIF puis WebP, largeurs responsives), dimensions explicites, aucune image de repli en production, zéro décalage de mise en page MVP §11 AC-3.2-52 : CLS ≤ 0,05 ; poids de l'image au-dessus de la ligne de flottaison ≤ 350 Ko sur mobile
FR-53 Indexation désactivée hors production (noindex, robots.txt restrictif, en-tête X-Robots-Tag) MVP §11 AC-3.2-53 : en staging, la réponse porte noindex et X-Robots-Tag, robots.txt interdit tout et sitemap.xml liste quand même les trois URL

3.2.11 Contenu, analytique, extension de réservation et exploitation #

ID Exigence Priorité Section Critère d'acceptation
FR-54 Contenu configurable dans des fichiers typés de src/content/fr/, validés par Zod à l'import ; une erreur de schéma fait échouer pnpm build avec un message lisible MVP §9.3 AC-3.2-54 : un prix non numérique dans offers.ts interrompt le build en nommant le fichier et le champ
FR-55 Architecture prête pour plusieurs langues : getContent(locale), contenu sous src/content/fr/, pas de préfixe de langue MVP §9.3 AC-3.2-55 : le français est la seule locale acceptée par le type locale ; ajouter un dossier de locale ne demande aucune modification des composants
FR-56 Éléments de développement (SVG de paysage, forme abstraite sans visage) sous public/images/dev-fallback/, interdits en production MVP §1.3.3, §6.2 AC-3.2-56 : G-02 fait échouer le build de production s'ils sont référencés
FR-57 Analytique optionnelle : ANALYTICS_PROVIDER=none (défaut) ou umami ; script chargé après l'hydratation (afterInteractive) avec data-do-not-track="true", data-exclude-search="true" et data-exclude-hash="true", aucun cookie ; bouton d'opposition qui écrit l'indicateur umami.disabled à la demande explicite du visiteur MVP §9.8, §10 AC-3.2-57 : par défaut, aucune requête vers un domaine tiers ; avec umami, un seul script tiers et aucun cookie
FR-58 Événements d'analytique limités à cta_click, contact_submit, method_step_open, jamais de donnée personnelle ni d'identifiant de situation de la boussole MVP §9.8 AC-3.2-58 : inspecter les charges utiles : aucune donnée de formulaire ni de boussole
FR-59 Extension de réservation : getBookingAction({ offerId? }) (module serveur uniquement) retourne l'action de réservation active ; les composants n'assemblent jamais d'URL et les îlots clients reçoivent les liens en propriétés ; Cal.com ajoutable ensuite sans refonte Évolution (point d'extension livré en MVP) §9 AC-3.2-59 : tous les CTA de réservation passent par getBookingAction ; une recherche de wa.me dans src/components ne trouve rien
FR-60 GET /api/health : 200 { "ok": true, "data": { "status": "ok", … } }, sans appel externe MVP §9.4 AC-3.2-60 : la réponse arrive en < 100 ms sans réseau sortant
FR-61 Journaux JSON sur la sortie standard avec identifiant de requête ; aucune donnée personnelle MVP §9.7 AC-3.2-61 : après une soumission de test, les journaux ne contiennent ni nom, ni e-mail, ni téléphone, ni message, ni IP complète
FR-62 Déploiement Docker sur VPS (multi-étapes, Compose, contrôle de santé, redémarrage automatique, HTTPS via Caddy ou proxy existant, compatibilité Coolify) MVP §14 AC-3.2-62 : docker compose up -d démarre le service ; web passe healthy ; les mises à jour et le retour arrière suivent §14

3.3 Exigences non fonctionnelles #

3.3.1 Performance #

ID Exigence Cible Section Critère d'acceptation
NFR-01 Largest Contentful Paint ≤ 2,5 s (p75 terrain, laboratoire mobile) §11 AC-3.3-01 : Lighthouse CI mobile sur / ≤ 2,5 s
NFR-02 Cumulative Layout Shift ≤ 0,05 §11 AC-3.3-02 : Lighthouse CI ≤ 0,05 ; aucune animation n'entraîne de décalage
NFR-03 Interaction to Next Paint ≤ 200 ms §11 AC-3.3-03 : INP mesuré en test E2E sur les interactions boussole, menu, formulaire ≤ 200 ms
NFR-04 JavaScript du premier chargement de / ≤ 170 Ko gzip §11, §9.1 AC-3.3-04 : le contrôle de budget de CI échoue au-delà
NFR-05 Poids des images au-dessus de la ligne de flottaison (mobile) ≤ 350 Ko en AVIF §11 AC-3.3-05 : mesuré en CI
NFR-06 Scores Lighthouse mobile Performance ≥ 90 ; Accessibilité 100 ; Bonnes pratiques ≥ 95 ; SEO 100 (mesuré sur le build de production ; l'audit is-crawlable est exclu hors production) §11 AC-3.3-06 : Lighthouse CI échoue sous ces seuils
NFR-07 Animations Uniquement transform, opacity, filter, clip-path ; jamais de largeur, hauteur, position ou marge animées ; animations d'ambiance limitées à un cycle unique de 5 s au plus par entrée dans la fenêtre, en pause onglet masqué §8 AC-3.3-07 : revue de code et test qui échoue si une propriété interdite est animée
NFR-08 Temps de blocage total (laboratoire) ≤ 200 ms §11 AC-3.3-08 : Lighthouse CI mobile
NFR-09 Chargement des polices Auto-hébergées au build ; aucune requête vers un service de polices à l'exécution ; métriques de repli ajustées §5, §11 AC-3.3-09 : aucune requête vers un domaine de polices tiers

3.3.2 Accessibilité (WCAG 2.2 niveau AA) #

ID Exigence Cible Section Critère d'acceptation
NFR-10 Conformité WCAG 2.2 AA sur toutes les pages §11 AC-3.3-10 : analyse axe (balises wcag2a, wcag2aa, wcag21aa, wcag22aa) sans violation sérieuse ou critique
NFR-11 Contrastes Texte ≥ 4,5:1 ; grand texte et composants d'interface ≥ 3:1 ; texte sur image uniquement avec voile garantissant ≥ 4,5:1 §5, §4.5 AC-3.3-11 : contrôle des paires de couleurs des jetons (§5) en test unitaire ; aucun texte hors jeton validé
NFR-12 Taille des cibles ≥ 24 × 24 px (critère 2.5.8) ; ≥ 44 × 44 px pour les cibles tactiles principales §5 AC-3.3-12 : test de géométrie sur boutons, liens de navigation, éléments de la boussole
NFR-13 Focus Indicateur visible sur tout élément interactif ; focus jamais masqué par le header fixe (critères 2.4.11) §5, §4.5 AC-3.3-13 : parcours Tab sur / : chaque élément focalisé est visible et non couvert
NFR-14 Adaptation à l'affichage Reflow à 320 px sans défilement horizontal ; zoom du texte 200 % et 400 % sans perte ; espacements de texte modifiables §4.6, §11 AC-3.3-14 : tests à 320 × 640 et à zoom 400 %
NFR-15 Mouvement prefers-reduced-motion respecté ; aucun contenu clignotant ; aucun défilement automatique ; aucune animation d'ambiance de plus de 5 s ni en boucle (WCAG 2.2.2) §8 AC-3.3-15 : tests avec émulation activée
NFR-16 Structure Repères (header, nav, main, footer), un seul h1, hiérarchie de titres sans saut, lien d'évitement §4.2, §11 AC-3.3-16 : test automatisé de la hiérarchie des titres
NFR-17 Formulaires Libellés visibles et associés, autocomplete adapté (name, email, tel), erreurs identifiées en texte et rattachées au champ, récapitulatif d'erreurs qui reçoit le focus, annonce aria-live §6.9 AC-3.3-17 : parcours lecteur d'écran du formulaire
NFR-18 Modes de contraste forcé Focus et frontières restent visibles en forced-colors: active §5 AC-3.3-18 : vérification en émulation de contraste forcé
NFR-19 Technologies d'assistance Vérification manuelle avant lancement avec VoiceOver (macOS et iOS), NVDA (Firefox ou Chrome) et TalkBack §15 AC-3.3-19 : liste de contrôle manuelle signée (§15)

3.3.3 Sécurité #

ID Exigence Cible Section Critère d'acceptation
NFR-20 En-têtes de sécurité Politique de sécurité du contenu stricte, HSTS (max-age d'un an avec includeSubDomains en production, 300 s en préproduction), X-Content-Type-Options, Referrer-Policy, Permissions-Policy, protection contre l'inclusion en cadre ; les en-têtes sont produits par un unique constructeur appelé par la configuration Next.js, le proxy n'en ajoute aucun §10 AC-3.3-20 : test d'en-têtes sur la réponse de / et de /api/contact
NFR-21 Secrets Uniquement dans l'environnement d'exécution ; aucun secret dans le dépôt, l'image, les journaux ou le JavaScript client ; aucune variable NEXT_PUBLIC_* §13, §10 AC-3.3-21 : analyse de l'image et du bundle client : aucune occurrence de la clé
NFR-22 Validation Toute entrée est validée côté serveur par Zod ; sorties d'e-mail échappées ; corps ≤ 16 Ko §9.4 AC-3.3-22 : tests d'injection HTML, de dépassement de taille, de JSON invalide
NFR-23 Protection du point d'API Contrôle de l'origine avant lecture du corps, limitation de débit, champ piège, aucun point d'API qui divulgue des détails techniques §9.4, §9.6 AC-3.3-23 : tests d'intégration de chaque code d'erreur
NFR-24 Conteneur durci Utilisateur non root, système de fichiers racine en lecture seule, toutes les capacités retirées, no-new-privileges, limite mémoire de 768 Mo §14 AC-3.3-24 : inspection de compose.yaml et du conteneur en cours
NFR-25 Dépendances Fichier de verrouillage versionné ; contrôle d'audit en CI sans vulnérabilité haute ou critique non justifiée §9.1, §15 AC-3.3-25 : étape d'audit de CI verte
NFR-26 Appels sortants Le serveur ne contacte que l'API du fournisseur d'e-mail ; le navigateur ne contacte aucun domaine tiers, sauf l'analytique si activée et les liens WhatsApp à l'initiative du visiteur §10 AC-3.3-26 : journal réseau d'une visite complète

3.3.4 Vie privée et RGPD #

ID Exigence Cible Section Critère d'acceptation
NFR-27 Cookies et stockage navigateur Aucun cookie, aucun localStorage ni sessionStorage, sauf l'indicateur umami.disabled écrit uniquement à la demande explicite du visiteur lorsque l'analytique est activée §10 AC-3.3-27 : avec ANALYTICS_PROVIDER=none, onglet Stockage vide après une visite complète ; avec umami, vide tant que le visiteur n'a pas cliqué sur le bouton d'opposition
NFR-28 Minimisation Le formulaire ne collecte que ce qui est nécessaire ; consentement explicite avant envoi §6.9, §10 AC-3.3-28 : envoi impossible sans la case de consentement
NFR-29 Journaux Aucun nom, e-mail, téléphone, message ni IP complète ; seulement un condensé tronqué de l'IP pour la limitation de débit §9.7 AC-3.3-29 : revue des journaux de test
NFR-30 Information du visiteur Politique de confidentialité exacte : finalités, bases légales (art. 6.1.b et 6.1.f du RGPD ; la case à cocher n'est pas la base légale), destinataires (Malka, fournisseur d'e-mail), durée de conservation fournie par Malka, droits, réclamation auprès de la CNIL, contact §10 AC-3.3-30 : relecture de la politique par Malka (registre G-05)
NFR-31 Sous-traitant d'e-mail Fournisseur établi en France et hébergeant dans l'UE §9.5, §10 AC-3.3-31 : mention dans la politique de confidentialité

3.3.5 Fiabilité #

ID Exigence Cible Section Critère d'acceptation
NFR-32 Indépendance de la page La page / est statique et reste servie même si le fournisseur d'e-mail est indisponible §9 AC-3.3-32 : fournisseur simulé en panne → / répond 200
NFR-33 Appels au fournisseur Délai maximal 8 s, un seul nouvel essai après 500 ms, uniquement sur erreur 5xx ou réseau §9.5 AC-3.3-33 : tests d'intégration avec faux serveur
NFR-34 Santé et relance Contrôle de santé toutes les 30 s ; redémarrage automatique §14 AC-3.3-34 : processus tué → conteneur relancé et healthy en < 30 s
NFR-35 Arrêt propre Le serveur traite SIGTERM et termine les requêtes en cours (délai maximal 10 s) §14 AC-3.3-35 : arrêt de conteneur pendant une requête de test → réponse complète
NFR-36 Limiteur de débit borné Taille mémoire plafonnée (10 000 clés maximum, éviction des plus anciennes) §9.6 AC-3.3-36 : test de saturation sans croissance mémoire illimitée
NFR-37 Erreurs de rendu Frontière d'erreur sur l'application ; aucun écran blanc §6.11 AC-3.3-37 : erreur simulée → page d'erreur française

3.3.6 Maintenabilité #

ID Exigence Cible Section Critère d'acceptation
NFR-38 Typage TypeScript en mode strict ; aucun any implicite ; aucun @ts-ignore sans justification §12 AC-3.3-38 : typecheck vert ; règle de lint sur any
NFR-39 Qualité de code ESLint et Prettier en CI ; règles d'accessibilité JSX activées §12 AC-3.3-39 : lint vert
NFR-40 Contenu typé Toutes les chaînes visibles viennent de src/content/fr/ ; aucune chaîne française en dur dans les composants, sauf libellés d'interface génériques centralisés §9.3, §12 AC-3.3-40 : test statique de détection de texte en dur
NFR-41 Couverture Seuils par dossier définis en §15.1.3 (dont ≥ 90 % de lignes sur src/lib/**) ; chaque code d'erreur de l'API couvert §15 AC-3.3-41 : seuils de couverture appliqués en CI
NFR-42 Jetons uniques Couleurs, polices, rayons, durées définis une fois (§5, §8) ; aucune valeur brute dupliquée §5, §8 AC-3.3-42 : recherche statique de valeurs hexadécimales hors src/styles/globals.css et src/lib/design/tokens.ts
NFR-43 Documentation README.md : les 16 rubriques de §18.9.1 (démarrage, modification du contenu, variables, déploiement, retour arrière, décisions) §12, §18.9 AC-3.3-43 : un nouveau développeur exécute le projet depuis README.md sans aide
NFR-44 Dépendances Version stable courante installée au build, fichier de verrouillage enregistré (voir §9.1) §9.1 AC-3.3-44 : pnpm install --frozen-lockfile réussit

3.3.7 Observabilité et exploitabilité #

ID Exigence Cible Section Critère d'acceptation
NFR-45 Journaux structurés Lignes JSON { ts, level, event, requestId, outcome, code?, durationMs } sur la sortie standard §9.7 AC-3.3-45 : validation du format en test
NFR-46 Traçabilité Chaque réponse d'API contient un requestId ; il figure dans les journaux §9.4, §9.7 AC-3.3-46 : corrélation d'une réponse d'erreur et de sa ligne de journal
NFR-47 Rotation des journaux json-file, 10 Mo × 5 fichiers §14 AC-3.3-47 : configuration Compose
NFR-48 Santé /api/health expose version (APP_VERSION) et durée de fonctionnement §9.4 AC-3.3-48 : la version déployée est lisible

3.3.8 Déployabilité #

ID Exigence Cible Section Critère d'acceptation
NFR-49 Build reproductible Installation avec --frozen-lockfile, image multi-étapes §14 AC-3.3-49 : deux builds du même commit donnent le même ensemble de dépendances
NFR-50 Taille de l'image ≤ 250 Mo §14 AC-3.3-50 : contrôle de CI
NFR-51 Mise à jour et retour arrière Mise à jour ≤ 10 min (build inclus) ; retour arrière ≤ 2 min ; coupure de service lors du remplacement ≤ 15 s §14 AC-3.3-51 : procédure jouée sur préproduction
NFR-52 Compatibilité Coolify Déploiement comme ressource Docker Compose sans le profil de proxy, avec l'option « Connect to Predefined Network » documentée §14 AC-3.3-52 : lecture et revue de compose.yaml ; procédure Coolify documentée
NFR-53 Configuration Variables validées au démarrage ; arrêt avec un message clair si une variable de production manque ou si EMAIL_DRY_RUN=true en production §13 AC-3.3-53 : démarrage sans BREVO_API_KEY en production → arrêt explicite

3.3.9 Évolutivité et internationalisation #

ID Exigence Cible Section Critère d'acceptation
NFR-54 Préparation i18n Contenu isolé par locale ; formats de nombres et dates via Intl §9.3 AC-3.3-54 : aucun format de date ou de prix codé en dur
NFR-55 Extension de réservation Un nouveau fournisseur de réservation s'ajoute sans modifier les composants de CTA §9 AC-3.3-55 : revue d'architecture

3.4 Matrice de compatibilité navigateurs et appareils #

Famille Versions prises en charge Test automatisé Vérification manuelle avant lancement
Chrome (ordinateur) 2 dernières versions majeures Oui (Chromium) Oui
Edge 2 dernières versions majeures Équivalent Chromium Oui
Firefox 2 dernières versions majeures Oui Oui
Safari macOS 2 dernières versions majeures Oui (WebKit) Oui
Safari iOS 17 et supérieures Émulation mobile WebKit Sur appareil réel
Chrome Android 2 dernières versions majeures Émulation mobile Chromium Sur appareil réel
Samsung Internet 2 dernières versions majeures Équivalent Chromium Oui
Largeur minimale 320 px Oui Oui

Requête de compatibilité à déclarer dans package.json :

{
  "browserslist": [
    "last 2 chrome versions",
    "last 2 edge versions",
    "last 2 firefox versions",
    "last 2 safari versions",
    "ios_saf >= 17",
    "last 2 and_chr versions",
    "last 2 samsung versions"
  ]
}

Règles :

  • Les navigateurs hors matrice (anciennes versions, navigateurs à moteur non standard) ne sont pas testés ; ils ne doivent toutefois pas faire échouer la lecture du contenu (amélioration progressive).
  • Tout effet visuel avancé a un repli lisible : backdrop-filter → fond opaque ; 100svh → repli 100vh ; mask-image/clip-path non pris en charge → affichage direct du texte, sans masque.
  • Le seul requis de rendu est le CSS moderne pris en charge par la matrice ci-dessus (couches @layer, variables CSS, clamp(), aspect-ratio, :focus-visible, prefers-reduced-motion).

3.5 Comportement sans JavaScript #

Le site est rendu côté serveur : le contenu est dans le HTML initial. Avec JavaScript désactivé ou en échec :

Élément Comportement garanti
Contenu de toutes les sections Lisible ; aucun contenu masqué par des styles initiaux d'animation (les états initiaux sont appliqués seulement après hydratation)
Titre h1 et LCP Peints dans le HTML initial
Navigation par ancres Fonctionne (liens HTML natifs) ; pas de mise en évidence de la section active
Menu mobile Les liens du menu sont présents dans le HTML ; sous @media (scripting: none), le header repasse en flux normal, le bouton burger est masqué et la liste des liens et le CTA s'affichent sous le logotype (aucun bouton inerte)
Méthode Les 4 explications sont visibles en liste ; les ancres #methode-… mènent à chaque bloc
Offres Les 4 offres et leurs liens WhatsApp (liens HTML natifs) fonctionnent
Boussole d'orientation Rendue côté serveur ; sous @media (scripting: none), remplacée par la liste statique des quatre situations (piste, questions, offre suggérée en texte, liens WhatsApp et e-mail génériques) ; aucun module inerte
Témoignages Si publiés : liste verticale de toutes les citations, sans slider ni contrôle
Formulaire de contact Absent du rendu ; remplacé par un bloc <noscript> affichant les liens mailto: et WhatsApp et le téléphone, avec un texte expliquant que le formulaire nécessite JavaScript
Liens légaux, footer Fonctionnent
Animations Aucune ; le rendu final est statique et complet

Règle de mise en œuvre : les bascules d'affichage sans JavaScript s'écrivent en CSS sous @media (scripting: none) dans src/styles/globals.css. Un bloc <noscript> n'est utilisé que pour remplacer le formulaire de contact. Les détails d'implémentation par section sont en §6.

3.6 Traçabilité des blocs A à J #

Bloc Section de la page Exigences fonctionnelles Spécification détaillée
A Navigation FR-01 à FR-08 §6.1
B Hero FR-09 à FR-15 §6.2
C Philosophie FR-16 §6.3
D Méthode FR-17 à FR-20 §6.4
E Accompagnements FR-21 à FR-25 §6.5
F Boussole d'orientation FR-26 à FR-28 §6.6
G À propos FR-29 §6.7
H Témoignages FR-30 à FR-33 §6.8
I Contact FR-34 à FR-41 §6.9
J Footer FR-42, FR-43 §6.10
Transverse Animation FR-44 à FR-46 §8
Transverse Pages légales et système, SEO FR-47 à FR-53 §6.11, §10, §11
Transverse Contenu, analytique, réservation, exploitation FR-54 à FR-62 §9, §13, §14

4. Architecture UX, parcours et wireframes #

Cette section fixe la structure de la page : ordre des sections, ancres, parcours visiteur, wireframes aux trois largeurs de référence, couches d'empilement et règles anti-chevauchement. Les dimensions typographiques, couleurs et espacements sont dans §5 ; le comportement détaillé de chaque section est dans §6. Quand une valeur numérique de cette section (dégagement minimal, largeur de colonne) et une valeur de §5 semblent différer, la plus contraignante pour éviter un chevauchement s'applique.

4.1 Architecture de la page et ordre des sections #

La landing page est une page unique, rendue statiquement, composée d'un en-tête, de huit sections (dont une conditionnelle) et d'un pied de page. Les pages légales, la 404 et la page d'erreur réutilisent l'en-tête simplifié et le pied de page.

4.1.1 Ordre et rythme #

Rang Bloc Élément Fond Rôle dans le parcours
— — Lien d'évitement « Aller au contenu » — Accessibilité (premier élément focalisable)
— A <header> fixe Transparent puis vert forêt translucide Orientation et accès permanent au contact
1 B #accueil — Hero « CLARTÉ » Sombre (forêt) Accroche, preuve de présence réelle, deux chemins : contact ou méthode
2 C #philosophie — Entreprise vivante Clair Comprendre la vision
3 D #methode — Boussole de clarté Sombre (forêt) Comprendre la démarche en quatre phases
4 E #accompagnements — Offres Clair Choisir une offre, connaître le prix
5 F #orientation — Boussole d'orientation Clair Se situer, obtenir une piste et une offre possible
6 G #a-propos — À propos de Malka Clair Confiance dans la personne
7 H #temoignages — Témoignages Clair Preuve sociale vérifiée (conditionnelle)
8 I #contact — Contact et prise de rendez-vous Clair Conversion
9 J <footer> — Passage & Clarté Sombre (forêt) Dernière chance de contact, navigation, mentions légales

Logique narrative : accueil (B) → je comprends la vision (C) → je vois comment Malka travaille (D) → je vois ce qui m'est proposé (E) → je me situe (F) → je fais confiance à la personne (G, H) → je contacte (I).

Vocabulaire des appels à l'action : « Prendre rendez-vous » (hero) et « Faire le point » (en-tête) mènent tous deux à la section Contact, où le bloc WhatsApp vient en premier (parcours J4). Aucun outil de réservation n'existe au lancement : le rendez-vous se convient directement avec Malka par WhatsApp ou par le formulaire.

4.1.2 Règle d'alternance des fonds clairs #

Les sections claires alternent deux teintes, ivory-50 et ivory-100 (jetons de §5). La teinte est calculée à partir du rang de rendu parmi les sections claires effectivement affichées, jamais codée en dur par section :

// pseudo-code de référence — la teinte dépend du rang parmi les sections claires rendues
const lightSections = renderedSections.filter((s) => s.tone === 'light');
lightSections.forEach((s, index) => {
  s.background = index % 2 === 0 ? 'ivory-50' : 'ivory-100';
});

Résultat avec toutes les sections : Philosophie ivory-50, Accompagnements ivory-100, Orientation ivory-50, À propos ivory-100, Témoignages ivory-50, Contact ivory-100. Sans Témoignages : Contact devient ivory-50. Les sections sombres (Hero, Méthode, Footer) ne comptent pas dans l'alternance.

4.1.3 Transitions entre sections #

Passage Traitement Contraintes
Hero → Philosophie Dégradé de fin de hero (forêt vers ivoire, avec un voile de lumière champagne) de hauteur clamp(96px, 14svh, 160px) ; la réserve basse du hero est au moins égale à cette hauteur Le dégradé est dans le hero ; aucun texte ni aucune cible interactive n'y est placé
Philosophie → Méthode Bord en dégradé clair vers sombre (jeton --edge-light-to-dark de §5.4.1), 96 px de hauteur, collé en haut de la section sombre ; une silhouette de crête décorative peut s'y superposer à très faible opacité aria-hidden="true", pointer-events: none, contenu dans la marge haute de la section
Méthode → Accompagnements Bord en dégradé sombre vers clair (jeton --edge-dark-to-light de §5.4.1), 96 px de hauteur, collé en bas de la section sombre Idem
Contact → Footer Silhouettes de montagnes faisant partie du footer (§6.10) ; la carte flottante du footer (position: relative, jamais fixe ni collante) recouvre la jonction de --footer-overlap (48 px, 64 px puis 80 px selon la largeur, jeton de §5.11) Au moins 64 px d'espace libre sont réservés en bas de #contact : aucun texte ni aucune cible interactive de Contact ne se trouve sous la carte ; aucune collision de texte

Padding vertical d'une section : jeton --spacing-section (§5.6.1), appliqué partout sans valeur en pixels propre à une section. Ce padding inclut la hauteur des bords en dégradé (ils ne réduisent pas la zone de contenu).

4.1.4 Carte verticale de la page #

 ┌ skip link (visible au focus)
 ├ HEADER fixe 72/64 (toujours visible)
 ├ #accueil          [SOMBRE]  hero (≥ 1024 : clamp(720px,100svh,1080px))   ← h1
 │   └ dégradé de transition clamp(96px,14svh,160px)
 ├ #philosophie      [CLAIR ]  éditorial                                 ← h2
 ├ #methode          [SOMBRE]  boussole 4 phases + panneau               ← h2
 ├ #accompagnements  [CLAIR ]  4 offres + mention de TVA                 ← h2
 ├ #orientation      [CLAIR ]  4 situations → piste + offre suggérée     ← h2
 ├ #a-propos         [CLAIR ]  portrait + présentation                   ← h2
 ├ #temoignages      [CLAIR ]  slider (si ≥ 1 témoignage publiable)      ← h2
 ├ #contact          [CLAIR ]  WhatsApp d'abord, puis formulaire         ← h2
 └ <footer>          [SOMBRE]  carte CTA flottante, navigation, coordonnées, liens légaux

4.2 Ancres et identifiants de référence #

Cette table est la référence unique des identifiants de la page. Aucun autre identifiant d'ancre ne doit être créé sans mise à jour de cette table.

4.2.1 Table des ancres #

Rang Section Balise et identifiant Libellé de navigation Fond Rendu
— Contenu principal <main id="contenu" tabindex="-1"> — (cible du lien d'évitement) — Toujours
— Haut de page id="haut" sur l'élément <body> — (cible du lien « Retour en haut » du pied de page) — Toujours
1 Hero <section id="accueil" tabindex="-1"> ; titre <h1 id="accueil-titre"> Logo (« Malka Debleds ») Sombre Toujours
2 Philosophie <section id="philosophie" tabindex="-1"> ; titre philosophie-titre Philosophie Clair Toujours
3 Méthode <section id="methode" tabindex="-1"> ; titre methode-titre Méthode Sombre Toujours
3a Étape Écouter <span id="methode-ecouter"> : repère vide de hauteur nulle, aria-hidden="true", placé en tête de la boussole ; onglet methode-tab-ecouter, panneau methode-panel-ecouter — (lien du rail du hero) — Toujours
3b Étape Explorer <span id="methode-explorer"> ; onglet methode-tab-explorer, panneau methode-panel-explorer — — Toujours
3c Étape Clarifier <span id="methode-clarifier"> ; onglet methode-tab-clarifier, panneau methode-panel-clarifier — — Toujours
3d Étape Décider <span id="methode-decider"> ; onglet methode-tab-decider, panneau methode-panel-decider — — Toujours
4 Accompagnements <section id="accompagnements" tabindex="-1"> ; titre accompagnements-titre Accompagnements Clair Toujours
5 Boussole d'orientation <section id="orientation" tabindex="-1"> ; titre orientation-titre Orientation Clair Toujours
6 À propos <section id="a-propos" tabindex="-1"> ; titre a-propos-titre À propos Clair Toujours
7 Témoignages <section id="temoignages" tabindex="-1"> ; titre temoignages-titre Témoignages Clair Seulement si au moins un témoignage est publiable (§6.8, §9.3)
8 Contact <section id="contact" tabindex="-1"> ; titre contact-titre Contact Clair Toujours
9 Footer <footer> (sans lien de navigation) — Sombre Toujours
— Menu mobile panneau id="menu-mobile" (role="dialog", aria-modal="true"), commandé par le bouton de menu (aria-controls="menu-mobile") — Forêt Sous 1280 px, au clic sur le bouton de menu

Ordre des liens du header (à partir de 1280 px) : Philosophie · Méthode · Accompagnements · Orientation · À propos · Témoignages (conditionnel) · Contact. Le CTA « Faire le point » (cible #contact) est séparé du groupe de liens. En dessous de 1280 px, les liens sont dans le panneau menu-mobile ; le CTA « Faire le point » reste visible dans la barre à partir de 640 px.

Libellés accessibles : le <nav> du header porte aria-label="Navigation principale" ; le groupe de liens du footer porte aria-label="Navigation du pied de page" ; le rail de méthode du hero est un <nav aria-label="Les quatre temps de la méthode"> dont chaque lien a pour nom accessible son libellé visible (« Écouter », « Explorer », « Clarifier », « Décider ») ; chaque section porte aria-labelledby="<id>-titre" où l'identifiant du titre est <id>-titre (par exemple philosophie-titre). Le titre du hero est le h1 de la page, identifié accueil-titre. Le lien lié à la section visible porte aria-current="location".

4.2.2 Règles #

  1. Les identifiants sont en minuscules ASCII sans accent, séparés par des tirets, uniques sur la page.
  2. Les liens de navigation sont lus depuis src/content/fr/navigation.ts ; chaque lien référence un identifiant déclaré dans une unique constante d'ancres (module anchors, emplacement en §12). Aucun composant n'écrit un #identifiant en dur.
  3. Un test automatisé (§15) échoue si un href="#…" de la page ne correspond à aucun id, ou si un id d'ancre est dupliqué. Les repères methode-<id> sont des <span> vides distincts des onglets (methode-tab-<id>) : aucun identifiant n'est donc porté deux fois.
  4. scroll-margin-top de chaque section = hauteur du header + 16 px (88 px à partir de 1024 px, 80 px en dessous) ; scroll-padding-top de l'élément html a la même valeur pour que le focus clavier ne soit jamais masqué par le header.
  5. Les liens profonds #methode-ecouter … #methode-decider sont écoutés (hashchange) : l'étape correspondante s'active, y compris au chargement initial de la page avec ce fragment.
  6. Après une navigation par ancre, le focus est placé sur la racine de la section cible (tabindex="-1", sans nouveau défilement) ; son nom accessible vient de aria-labelledby, ce qui permet aux utilisateurs de lecteurs d'écran et de clavier de se retrouver dans la bonne section. Pour #accueil, le focus va sur la section du hero ; pour #contenu, sur <main> ; pour une étape #methode-<id>, sur la racine de la section Méthode.
  7. Sur les pages légales, les liens du header deviennent /#<ancre> ; le logo mène à / ; aucune mise en évidence de section active.
  8. Comportement d'un fragment inconnu (/#inexistant) : la page s'affiche en haut, sans erreur.

4.3 Parcours utilisateurs #

Chaque parcours liste les étapes, l'état du système et les événements d'analytique émis uniquement si l'analytique est activée (§9.8). Les critères d'acceptation AC-4.3-nn sont repris par les tests E2E de §15.

4.3.1 J1 — Du hero au formulaire de contact (ordinateur, souris) #

Étape Action du visiteur Réponse du système
1 Arrive sur / Le titre et la description sont déjà dans le HTML ; la séquence d'entrée (≤ 2,2 s) révèle les éléments ; l'élément LCP est le <h1> ou le calque lointain marqué data-lcp, et n'est jamais à opacity: 0
2 Lit le titre, regarde le portrait Parallaxe légère du paysage au défilement (uniquement à partir de 1024 px, pointeur fin, mouvement non réduit) ; le texte du hero ne bouge jamais ni ne s'estompe
3 Clique sur « Prendre rendez-vous » Événement cta_click (hero_rdv) ; défilement vers #contact (instantané en mouvement réduit) ; la racine de la section #contact reçoit le focus et son titre reste visible sous le header
4 Parcourt le bloc WhatsApp puis clique dans « Nom » Le formulaire note l'horodatage de première interaction (startedAt)
5 Remplit les champs Validation d'un champ à sa sortie, seulement s'il a été modifié ; messages en français sous le champ
6 Coche le consentement et clique sur « Envoyer ma demande » Validation complète ; en cas d'erreur, le focus va sur le récapitulatif des erreurs, qui annonce leur nombre et lie chaque message à son champ
7 — Pendant l'envoi : aria-busy="true" sur le formulaire, champs en lecture seule, bouton aria-disabled="true" (il garde le focus), indicateur de chargement ; une seule requête part
8 Lit la confirmation Succès : message de confirmation (focus sur le message) ; événement contact_submit (success) ; le formulaire n'est plus soumettable de nouveau avant réinitialisation volontaire
  • AC-4.3-01 : du chargement à la confirmation, le parcours J1 se déroule sans erreur de console, avec au plus 3 interactions avant d'atteindre le formulaire (clic CTA, défilement automatique, clic sur le premier champ).
  • AC-4.3-02 : après le clic sur « Prendre rendez-vous », la section #contact porte le focus et son titre est visible sous le header (non masqué).
  • AC-4.3-03 : l'envoi valide affiche la confirmation au plus 1 s après la réponse du serveur simulé (horloge de test avancée du délai minimal de remplissage défini en §9.4.8) et empêche un second envoi pendant la requête.

4.3.2 J2 — D'une offre vers WhatsApp #

Étape Action du visiteur Réponse du système
1 Arrive dans #accompagnements (défilement ou lien du header) Les quatre offres sont visibles (une offre mise en avant en panneau sombre, les autres en lignes éditoriales), avec « Tarif » et le prix, ou « Sur devis », et la mention de TVA sous la liste
2 Clique sur le CTA WhatsApp d'une offre Événement cta_click (offer_whatsapp, offer_id) ; ouverture de https://wa.me/33667295179?text=… dans un nouvel onglet (rel="noopener noreferrer")
3 Sur mobile L'application WhatsApp s'ouvre avec le message prérempli : « Bonjour Malka, je souhaite en savoir plus sur « {titre de l'offre} ». Pouvons-nous échanger ? »
4 Sur ordinateur La page wa.me propose WhatsApp Web ou l'application ; l'onglet du site reste ouvert
5 Variante : clique sur « Écrire via le formulaire » de l'offre Le lien est href="/?offre=<id>#contact" (il fonctionne sans JavaScript) ; événement cta_click (offer_form, offer_id) ; requestContactPrefill({ offerId }) mémorise l'offre en mémoire et la page défile vers #contact ; le champ « Offre concernée » est présélectionné ; le message reste vide. Sur un chargement direct de l'URL, le formulaire lit ?offre une seule fois au montage puis history.replaceState retire le paramètre de l'adresse
  • AC-4.3-04 : pour chacune des quatre offres, le lien WhatsApp contient le titre exact de l'offre, correctement encodé, et le numéro 33667295179.
  • AC-4.3-05 : le lien WhatsApp déclare un texte masqué « (ouvre WhatsApp dans un nouvel onglet) ».
  • AC-4.3-06 : la variante formulaire présélectionne l'offre correspondante (offerId), ne modifie ni le nom, ni l'e-mail, ni le message, et l'adresse affichée ne contient plus ?offre une fois le formulaire monté.

4.3.3 J3 — De la boussole d'orientation à un formulaire prérempli #

Étape Action du visiteur Réponse du système
1 Arrive dans #orientation État initial : quatre situations, panneau de résultat vide avec une invitation à choisir ; mise en garde globale visible
2 Choisit « Fatigue » (clic, toucher, ou flèches du clavier) Le choix est une case radio native cochée (les flèches sélectionnent immédiatement) ; le panneau affiche la piste de réflexion, 2 à 3 questions et l'offre suggérée (lecture-globale) ; annonce vocale polie ; aucun focus volé
3 Change d'avis, choisit « Transition » Le panneau est remplacé (fondu court) ; l'offre suggérée devient suivi-transition
4 Clique sur « Écrire via le formulaire » dans le panneau (l'autre CTA du panneau est « Écrire sur WhatsApp ») Événement cta_click (orientation_contact) sans la situation ni l'offre ; requestContactPrefill({ offerId, situationId }) mémorise offre et situation en mémoire seulement (jamais dans une adresse) ; défilement vers #contact ; les listes « Offre concernée » et « Votre situation (facultatif) » sont présélectionnées et restent modifiables
5 Complète le formulaire Parcours identique à J1 à partir de l'étape 5 ; le message n'est jamais prérempli
  • AC-4.3-07 : aucun résultat de boussole n'est stocké (aucun cookie ni stockage navigateur), aucune adresse affichée ou chargée ne contient la situation, et rien n'est envoyé au serveur avant l'envoi du formulaire.
  • AC-4.3-08 : la mise en garde « ne constitue ni un diagnostic ni une promesse de résultat » est visible avec chaque résultat.
  • AC-4.3-09 : l'événement d'analytique ne contient jamais l'identifiant de la situation.
  • AC-4.3-10 : sur mobile, si le panneau de résultat est hors de l'écran après une sélection au pointeur ou au toucher, la page défile jusqu'à le rendre visible sans masquer le choix (instantané en mouvement réduit) ; la navigation aux flèches du clavier ne fait jamais défiler la page — détail en §6.6.

4.3.4 J4 — Contact rapide par WhatsApp sur mobile (390 px) #

Étape Action du visiteur Réponse du système
1 Arrive sur / Premier écran : titre, description, deux CTA pleine largeur (hauteur ≥ 52 px) ; début de la bande portrait visible
2 Touche « Prendre rendez-vous » (ou ouvre le menu puis « Faire le point » ; en dessous de 640 px le CTA du header n'est que dans le menu) Défilement vers #contact ; le bloc WhatsApp est le premier élément de la section
3 Touche « Écrire sur WhatsApp » (bouton ≥ 56 px de haut) Événement cta_click (contact_whatsapp) ; l'application WhatsApp s'ouvre sur la conversation, message générique prérempli défini en §7
4 Revient au navigateur Le site est resté en l'état ; aucune relance ni fenêtre
  • AC-4.3-11 : depuis le premier écran, WhatsApp s'atteint en 2 touches (CTA du hero, puis bouton WhatsApp) ou 3 touches via le menu.
  • AC-4.3-12 : aucun élément fixe en bas d'écran ne masque le contenu ou ne gêne la zone de saisie (pas de barre collante, voir §4.5).

4.3.5 J5 — Visiteur au clavier seul #

Étape Action (clavier) Réponse attendue
1 Tab depuis le haut de page Le lien « Aller au contenu » devient visible et focalisé
2 Entrée Le focus passe sur <main id="contenu"> ; la page défile au contenu
3 Tab dans le header À partir de 1280 px : logo → liens de navigation (dans l'ordre de §4.2) → CTA « Faire le point » ; en dessous de 1280 px : logo → CTA (à partir de 640 px) → bouton de menu
4 Menu mobile : Entrée sur le bouton Le panneau menu-mobile s'ouvre en plein écran ; focus piégé dans le panneau (barre propre : logo et bouton de fermeture) ; Échap ferme et rend le focus au bouton ; un clic sur le fond ne ferme pas
5 Tab dans le hero CTA principal → CTA secondaire → 4 liens du rail (Écouter … Décider) ; les chiffres de confiance ne sont pas focalisables
6 Méthode Un seul arrêt de tabulation sur le groupe d'onglets de phases ; les flèches gauche/droite (ou haut/bas) déplacent le focus et activent l'étape (activation automatique) ; le panneau est annoncé
7 Accompagnements Pour chaque offre : résumé « Détails » (élément natif <details>), puis lien WhatsApp, puis lien formulaire
8 Boussole d'orientation Un seul arrêt sur le groupe de situations (cases radio natives) ; les flèches changent et sélectionnent immédiatement la situation ; Tab mène aux deux CTA du résultat (« Écrire sur WhatsApp », « Écrire via le formulaire »)
9 Témoignages (si au moins deux) Boutons précédent/suivant, indicateurs ; Entrée ou Espace activent un bouton ou un indicateur ; les contenus non actifs ne sont pas focalisables
10 Contact WhatsApp, téléphone, e-mail, puis champs dans l'ordre visuel (dont les listes « Offre concernée » et « Votre situation (facultatif) »), case de consentement, bouton d'envoi
11 Footer Carte CTA (« Écrire sur WhatsApp », « Écrire via le formulaire »), liens de navigation, coordonnées, liens légaux, « Retour en haut »
  • AC-4.3-13 : l'ordre de tabulation est strictement l'ordre visuel de gauche à droite et de haut en bas ; aucun tabindex positif n'existe.
  • AC-4.3-14 : chaque élément focalisé affiche l'indicateur de focus de §5 et n'est jamais couvert par le header.
  • AC-4.3-15 : aucun piège de focus hors du menu ouvert ; toute fonction accessible à la souris l'est au clavier.

4.3.6 J6 — Visiteur avec lecteur d'écran #

Aspect Comportement attendu
Langue lang="fr" sur la page ; la prononciation du mot « CLARTÉ » décoratif est évitée (masqué)
Repères banner, navigation (nommée), main, contentinfo ; chaque section est une région nommée par son titre
Titres Un h1 (hero), puis un h2 par section, h3 au besoin ; aucun saut de niveau
Éléments décoratifs Paysages, halos, brume, rivière, mot CLARTÉ, bords en dégradé : aria-hidden="true" ou absents de l'arbre d'accessibilité
Images Portrait : texte alternatif descriptif (validé, M-21) ; paysage : décoratif (alt="") sauf s'il porte une information
Rail de méthode <nav aria-label="Les quatre temps de la méthode"> contenant une liste de 4 liens dont le nom accessible est le libellé visible (« Écouter », « Explorer », « Clarifier », « Décider »)
Boussole de clarté Groupe d'onglets nommé (« Boussole de clarté ») ; onglet actif annoncé ; contenu du panneau annoncé de façon polie ; panneaux inactifs masqués de l'arbre (inert)
Boussole d'orientation Groupe de cases radio natives (radiogroup, fieldset et legend) à sélection unique ; résultat annoncé par une région aria-live="polite" ; mise en garde lue avec le résultat
Témoignages <section aria-roledescription="carrousel"> nommée ; chaque témoignage est un role="group" avec aria-roledescription="témoignage" et sa position annoncée (« 1 sur 2 ») ; contenus non actifs masqués de l'arbre ; région vocale désactivée jusqu'à la première interaction puis polie
Formulaire Champs étiquetés ; erreurs liées aux champs et listées dans un récapitulatif qui reçoit le focus ; état d'envoi (aria-busy) et résultat annoncés ; case de consentement étiquetée par une phrase complète
Liens externes Les liens WhatsApp annoncent « (ouvre WhatsApp dans un nouvel onglet) »
  • AC-4.3-16 : la navigation par repères et par titres au lecteur d'écran donne une structure complète et sans doublon.
  • AC-4.3-17 : aucune information n'est transmise uniquement par la couleur, la position ou une animation.

4.3.7 J7 — Visiteur avec mouvement réduit #

Aspect Comportement attendu
Hero Contenu visible immédiatement ou par fondu ≤ 0,2 s ; titre sans révélation par masque ; portrait et mot CLARTÉ fixes (fondu seul) ; aucune parallaxe
Défilement scroll-behavior: auto : les ancres sautent sans animation
Révélations au défilement Remplacées par un affichage immédiat ou un fondu ≤ 0,2 s
Boussole de clarté, boussole d'orientation Changement de contenu instantané ou par simple fondu (≤ 0,2 s)
Slider de témoignages Changement instantané ; jamais de glissement
Éléments atmosphériques Halos, brume, rivière : immobiles (aucune boucle)
Micro-interactions Seuls des changements de couleur ou d'opacité sont permis au survol et au focus ; aucun déplacement ni échelle
  • AC-4.3-18 : avec prefers-reduced-motion: reduce émulé, aucun élément n'a de transform animé et aucune boucle ne tourne ; tous les contenus restent accessibles.

4.3.8 J8 — Visite et retour depuis les pages légales #

Étape Action Réponse
1 Clique sur « Mentions légales » dans le footer Navigation vers /mentions-legales ; page statique avec en-tête simplifié ; titre h1 ; date de dernière mise à jour
2 Lit la page, clique sur « Contact » dans le header Navigation vers /#contact ; la page d'accueil s'affiche, la section Contact est en vue
3 Variante : clique sur le logo Retour à / (haut de page)
4 Variante : bouton « Retour » du navigateur La position de défilement précédente est restaurée
5 Va sur /politique-de-confidentialite depuis le footer (même onglet) ou depuis le formulaire (lien dans la phrase de consentement) Depuis le formulaire, le lien s'ouvre dans un nouvel onglet (rel="noopener", mention masquée « (s'ouvre dans un nouvel onglet) ») : le formulaire garde son état et le texte saisi n'est pas perdu
  • AC-4.3-19 : les pages légales sont accessibles en moins de 2 clics depuis n'importe quelle section de / (footer) et depuis la case de consentement du formulaire (nouvel onglet, formulaire intact).
  • AC-4.3-20 : sur ces pages, aucun lien de navigation n'est marqué comme actif ; tous les liens du header pointent vers /#<ancre>.

4.3.9 J9 — Reprise après échec d'envoi #

Cas Code Réaction de l'interface
Champ invalide (serveur) VALIDATION_ERROR (422) Messages par champ issus de error.fields ; le focus va sur le récapitulatif des erreurs
Trop de demandes RATE_LIMITED (429) Message « trop de demandes » avec le délai d'attente (Retry-After) ; alternatives WhatsApp et e-mail proposées ; bouton d'envoi aria-disabled jusqu'à l'expiration du délai ; le focus va sur la bannière d'erreur
Échec d'envoi du courriel EMAIL_DELIVERY_FAILED (502) Message d'erreur clair ; alternatives WhatsApp et e-mail ; texte saisi conservé
Erreur serveur INTERNAL_ERROR (500) Idem, avec invitation à réessayer
Origine refusée / média / taille / JSON 403, 415, 413, 400 Message générique d'erreur et alternatives ; ne se produit pas en usage normal
Réseau indisponible ou aucune réponse après 30 s (délai du client) — Message « connexion impossible » ; bouton réactivé ; texte conservé
  • AC-4.3-21 : après chaque échec, le visiteur a toujours un moyen d'aboutir (WhatsApp, téléphone ou e-mail) et ne perd jamais son texte.
  • AC-4.3-22 : l'échec est annoncé par la prise de focus : sur le récapitulatif des erreurs pour une validation, sur la bannière d'erreur pour un échec serveur ou réseau ; aucun role="alert" supplémentaire n'est ajouté, afin d'éviter une double annonce.

4.3.10 J10 — JavaScript désactivé ou en échec #

Étape Résultat attendu
Lecture Toutes les sections sont lisibles ; le titre est visible
Navigation Les liens d'ancre fonctionnent ; le menu mobile est remplacé par une liste de liens visible
Offres Les liens WhatsApp fonctionnent (liens natifs) ; le lien « Écrire via le formulaire » mène à /?offre=<id>#contact
Méthode Les quatre étapes sont listées l'une sous l'autre (les liens du rail mènent aux ancres methode-<id>)
Orientation La liste statique des quatre situations avec leur piste est affichée à la place du sélecteur (@media (scripting: none))
Témoignages Tous les témoignages publiables sont listés l'un sous l'autre ; les contrôles du carrousel sont masqués
Contact Le formulaire est remplacé par un bloc listant WhatsApp, téléphone et e-mail, avec une phrase expliquant que le formulaire demande JavaScript
  • AC-4.3-23 : avec JavaScript désactivé, un visiteur peut lire tout le contenu et contacter Malka par au moins un moyen (lien natif).

4.4 Wireframes #

4.4.1 Conventions de lecture #

  • Trois largeurs de référence : ordinateur 1440 px, tablette 834 px, mobile 390 px. Les schémas sont proportionnels à l'horizontale (1 caractère ≈ 12 px à 1440 px, ≈ 11,6 px à 834 px, ≈ 10,3 px à 390 px) mais non à la verticale.
  • Les textes entre crochets, comme [H2 éditorial], décrivent le rôle d'un contenu défini en §7 ; ils ne sont pas du texte final. Les titres d'offres, les prix et les libellés de navigation sont les valeurs réelles.
  • ( … ) = bouton ou lien d'action ; [ … ] = contenu ou contrôle ; * = élément conditionnel ; {…} = cadre d'image.
  • Les wireframes fixent la structure, l'ordre de lecture, les alignements et les dégagements. Les tailles de police et les espacements exacts sont dans §5. L'ordre du DOM est toujours l'ordre de lecture indiqué.
  • Pour les largeurs intermédiaires, les règles de §4.6 s'appliquent.

4.4.2 Hero « CLARTÉ » (bloc B) #

Ordinateur — 1440 px (de 1024 px et plus) #
+----------------------------------------------------------------------------------------------------------------------+
| [Malka Debleds]  Philosophie Méthode Accompagnements Orientation À propos Témoignages* Contact  (Faire le point)     |
|     header 72 px — transparent sur le paysage ; fond forêt translucide après 24 px de défilement                     |
|                                                                                                                      |
|      Accompagnement vibratoire et stratégique des entrepreneurs     +----------------------------+                   |
|                                                                     |                            |                   |
|      Retrouver la clarté pour                                       |                            |   (01 Écouter)    |
|      faire grandir une entreprise                                   |                            |        |          |
|      alignée.                                                       |                            |   (02 Explorer)   |
|                                                                     |                            |        |          |
|      Une écoute profonde, une lecture claire et                     |       PORTRAIT RÉEL        |   (03 Clarifier)  |
|      des actions concrètes pour avancer avec                        |(détouré, fond transparent) |        |          |
|      plus de sens, de cohérence et de justesse.                     |                            |   (04 Décider)    |
|                                                                     |                            |                   |
|      (Prendre rendez-vous)  (Découvrir la méthode)                  |                            |                   |
|            C       L       A                                        |      R      T      É       |                   |
|                                                                     |                            |                   |
|      300+ personnes accompagnées    50+ personnes formées           |                            |                   |
|                                                                     +----------------------------+                   |
|                                                                                                                      |
|  ~~ dégradé de transition forêt -> ivoire, clamp(96px, 14svh, 160px), vers #philosophie ~~~~~~~~~~~~~~~~~~           |
+----------------------------------------------------------------------------------------------------------------------+

Repères et contrat de mise en page (≥ 1024 px) :

Repère Règle
Paysage Trois calques plein écran (ciel et sommets, vallée et brume, crête proche) derrière tout le contenu, de gauche vert forêt à droite lumière champagne
Grille Contenu de 1280 px maximum, gouttières de 64 px de chaque côté en plus (conteneur total de 1408 px) ; deux colonnes minmax(0, 7fr) minmax(0, 5fr) avec un espace de 24 px. Largeurs de colonnes : texte ≈ 509 / 658 / 733 px et portrait ≈ 363 / 470 / 523 px à 1024 / 1280 / 1440 px
Colonne de texte Colonne de gauche, centrée verticalement ; commence à 24 px au moins sous le header. Ordre : sur-titre (positionnement), H1, description, CTA, chiffres de confiance. Le H1 utilise le jeton display-xl (clamp(2.25rem, 4.4vw, 3.75rem) à partir de 1024 px) sur trois lignes fixes sans retour automatique à la ligne (white-space: nowrap) ; le bord droit du bloc titre + 24 px ≤ le bord gauche du portrait
Voile de lisibilité Dégradé horizontal --scrim-hero-x (valeurs et jeton en §5.4.1, empilement en §4.5.2 et §4.5.3) sous la colonne de texte ; opacité de forêt au moins égale à 0,80 sous tout bloc de texte
Portrait Colonne de droite, ratio 4:5, ancré en bas du hero et au bord gauche de sa colonne, détouré, profondeur par ombre portée douce et halo champagne derrière ; sa largeur est celle de la colonne (≈ 363 / 470 / 523 px à 1024 / 1280 / 1440 px et au-delà)
Mot CLARTÉ Centré horizontalement sur le hero (le portrait masque les dernières lettres, c'est l'effet de profondeur voulu), derrière le portrait ; Fraunces graisse 600, font-size: clamp(14rem, 21vw, 22rem) ; couleur champagne-200 à plat à l'opacité 0,14 ; peut passer sous la colonne de texte uniquement parce que cette opacité garantit un contraste ≥ 10:1 pour le texte (§4.5.3) ; aria-hidden, pointer-events: none
Rail de méthode À partir de 1280 px : colonne de quatre entrées positionnée de façon absolue dans la zone de gouttière droite, hors de la grille, centrée verticalement et alignée sur le bord droit du conteneur (avec un débord de 32 px dans la gouttière), jamais sur le bord de la fenêtre. Chaque entrée = repère + numéro + libellé horizontal, dans une capsule forest-950 à 0,80 d'opacité, texte ivory-50 ; point actif champagne ; cible ≥ 44 px de haut ; nom accessible = libellé visible. Si la gouttière est plus étroite que le rail (1280 à 1535 px), la boîte du portrait est réduite par la droite pour que portrait et rail restent à 24 px au moins l'un de l'autre. De 1024 à 1279 px : le rail devient une rangée horizontale de 4 étapes sous les chiffres de confiance, dans la colonne de texte
Hauteur clamp(720px, 100svh, 1080px) à partir de 1024 px uniquement (en dessous : hauteur du contenu) ; le contenu peut agrandir le hero, jamais le rogner
Transition Bandeau de dégradé de clamp(96px, 14svh, 160px) en bas du hero, sans contenu

Dégagements obligatoires : texte ↔ portrait ≥ 24 px ; portrait ↔ rail ≥ 24 px ; CTA ↔ chiffres de confiance ≥ 24 px ; aucun texte ni lien dans le dégradé de transition.

Ordinateur compact — 1024 à 1279 px #
+----------------------------------------------------------------------------------------------------------------------+
|  [Malka Debleds]  (logo)                                             (Faire le point)   [≡]     — de 1024 à 1279 px  |
|                                                                                                                      |
|      Accompagnement vibratoire et stratégique des entrepreneurs     +----------------------------+                   |
|      Retrouver la clarté pour                                       |                            |                   |
|      faire grandir une entreprise                                   |                            |                   |
|      alignée.                                                       |                            |                   |
|      Une écoute profonde, une lecture claire et des actions…        |       PORTRAIT RÉEL        |                   |
|      (Prendre rendez-vous)  (Découvrir la méthode)                  |                            |                   |
|      300+ personnes accompagnées    50+ personnes formées           |                            |                   |
|                                                                     +----------------------------+                   |
|      [01 Écouter]  [02 Explorer]  [03 Clarifier]  [04 Décider]   ← rail horizontal, sous les chiffres                |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px (768 à 1023 px) #
+----------------------------------------------------------------------+
|   [Malka Debleds]                           (Faire le point)   [≡]   |
|   header 64 px                                                       |
|                                                                      |
|   Accompagnement vibratoire et stratégique des entrepreneurs         |
|   Retrouver la clarté pour faire grandir                             |
|   une entreprise alignée.                                            |
|                                                                      |
|   Une écoute profonde, une lecture claire et des actions             |
|   concrètes pour avancer avec plus de sens, de cohérence             |
|   et de justesse.                                                    |
|                                                                      |
|   (Prendre rendez-vous)  (Découvrir la méthode)                      |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |                                                                | |
|   |         bande encadrée 16:10 — hero-mobile (cover)             | |
|   |          C L A R T É  (réduit, derrière le portrait)           | |
|   |             PORTRAIT RÉEL — centré, aligné en bas              | |
|   |                                                                | |
|   +----------------------------------------------------------------+ |
|                                                                      |
|   300+ personnes accompagnées        50+ personnes formées           |
|                                                                      |
|   [1 Écouter]  [2 Explorer]  [3 Clarifier]  [4 Décider]              |
|   (rail horizontal, 4 liens vers #methode-ecouter … #methode-decider)|
|                                                                      |
+----------------------------------------------------------------------+
Mobile — 390 px (moins de 768 px) #
+------------------------------------+
|  [Malka Debleds]               [≡] |
|  header 64 px                      |
|                                    |
|  Accompagnement vibratoire et      |
|  stratégique des entrepreneurs     |
|                                    |
|  Retrouver la clarté               |
|  pour faire grandir une            |
|  entreprise alignée.               |
|                                    |
|  Une écoute profonde, une          |
|  lecture claire et des actions     |
|  concrètes pour avancer avec       |
|  plus de sens, de cohérence        |
|  et de justesse.                   |
|                                    |
|  +--------------------------------+|
|  |      Prendre rendez-vous       ||
|  +--------------------------------+|
|  +--------------------------------+|
|  |      Découvrir la méthode      ||
|  +--------------------------------+|
|                                    |
|  +--------------------------------+|
|  |    bande 9:10 (≈ 342 × 380)    ||
|  |      hero-mobile (cover)       ||
|  |      C L A R T É (réduit)      ||
|  |         PORTRAIT RÉEL          ||
|  |     centré, aligné en bas      ||
|  |                                ||
|  +--------------------------------+|
|                                    |
|  300+                  50+         |
|  personnes             personnes   |
|  accompagnées          formées     |
|                                    |
|  1          2           3         4|
|  Écouter Explorer Clarifier Décider|
|                                    |
+------------------------------------+

Recomposition sous 1024 px (décision D-20) :

  1. L'ordre est : sur-titre → titre → description → CTA → bande portrait → chiffres de confiance → rail horizontal. Le texte n'est jamais superposé à une image et aucune marge négative n'est utilisée : le fond du hero est un dégradé forêt uni, et le paysage n'apparaît que dans la bande encadrée. Le LCP est le texte du titre. Le titre utilise le jeton display-xl en variante clamp(2.25rem, 7vw, 3.25rem).
  2. La bande a un rayon de 20 px, une bordure champagne à 30 % d'opacité, overflow: clip. Elle contient dans cet ordre : paysage aplati hero-mobile (object-fit: cover, utilisé uniquement dans cette bande), mot CLARTÉ (font-size: clamp(4rem, 20vw, 7.5rem), champagne-200 à plat à l'opacité 0,14, centré), portrait (hauteur 92 % de la bande, aligné en bas, centré). Ratio : 16:10 de 768 à 1023 px, 9:10 en dessous de 768 px.
  3. Les CTA sont en pleine largeur sur mobile (hauteur ≥ 52 px, espacement vertical ≥ 12 px) et côte à côte en tablette.
  4. Le rail horizontal a quatre liens de largeur égale ; chaque cible mesure ≥ 44 px de haut ; libellés jamais tronqués (12 px à 320 px de large, 13 px au-delà) ; le numéro est au-dessus du libellé.
  5. Sur mobile, le premier écran (390 × 844) montre le sur-titre, le titre, la description, les deux CTA et le début de la bande portrait. Sous 1024 px, la hauteur du hero est celle de son contenu (pas de plein écran forcé) ; en orientation paysage sur téléphone (hauteur < 600 px), le hero n'est jamais forcé à 100svh.
  6. Pas de parallaxe ni de flou sous 1024 px ou avec pointeur grossier (§8).

4.4.3 Navigation (bloc A) #

Ordinateur — 1440 px (liens en ligne à partir de 1280 px) #
+----------------------------------------------------------------------------------------------------------------------+
|   [Malka Debleds]   Philosophie Méthode Accompagnements Orientation À propos Témoignages* Contact  (Faire le point)  |
|                     _____________                                                                                    |
|   état haut de page : transparent      actif = trait champagne + aria-current="location"                             |
|                                                                                                                      |
|   [Malka Debleds]   Philosophie Méthode Accompagnements Orientation À propos Témoignages* Contact  (Faire le point)  |
|   état défilé (> 24 px, retour à « haut » sous 8 px) : fond forêt translucide, flou d'arrière-plan dès 768 px        |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px (768 à 1023 px : barre de 64 px ; de 1024 à 1279 px : même barre, hauteur 72 px) #
+----------------------------------------------------------------------+
|   [Malka Debleds]                           (Faire le point)   [≡]   |
|   bouton menu : 44 × 44 px, aria-expanded, libellé « Ouvrir le menu »|
+----------------------------------------------------------------------+
Mobile — 390 px (menu fermé, puis ouvert) #
+------------------------------------+
|  [Malka Debleds]               [≡] |
|  CTA du header masqué sous 640 px  |
+------------------------------------+
|  [Malka Debleds]              [ X ]|
|  (barre propre du panneau)         |
+------------------------------------+
|  Philosophie                       |
|  Méthode                           |
|  Accompagnements                   |
|  Orientation                       |
|  À propos                          |
|  Témoignages*                      |
|  Contact                           |
|                                    |
|  +--------------------------------+|
|  |         Faire le point         ||
|  +--------------------------------+|
|                                    |
|  (panneau plein écran 100dvh,      |
|  fond forêt, défilement de fond    |
|  bloqué)                           |
+------------------------------------+

Contrat du header et du menu :

  • Le header est toujours visible (sticky, jamais masqué au défilement). États : top (transparent), scrolled (après 24 px de défilement, retour sous 8 px) et solid (fond plein dès le chargement, pages légales). Fond forêt-950 à 0,92 d'opacité sous 768 px ; à 0,85 avec backdrop-filter: blur(12px) à partir de 768 px (repli à 0,97 si le flou n'est pas pris en charge).
  • Liens en ligne à partir de 1280 px ; en dessous, bouton de menu (le CTA « Faire le point » reste visible à partir de 640 px).
  • Le menu est une boîte de dialogue personnalisée role="dialog" aria-modal="true" identifiée menu-mobile, plein écran, avec sa propre barre (logo et bouton de fermeture) ; bouton aria-expanded + aria-controls="menu-mobile" ; padding-bottom: max(32px, env(safe-area-inset-bottom)) ; piège de focus ; Échap ferme et rend le focus au bouton ; pas de fermeture au clic sur le fond ; verrouillage du défilement de fond par html[data-scroll-locked] ; clic sur un lien ferme et défile vers la section ; liens ≥ 56 px de haut ; CTA ≥ 52 px de haut ; couche --z-menu (§4.5.1). Animation : ouverture en fondu et translation de −12 px vers 0 en durée fast ; fermeture en fondu instant ; ouverture et fermeture instantanées en mouvement réduit.

4.4.4 Philosophie — Entreprise vivante (bloc C) #

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|                                                                                                                      |
|       [H2 éditorial monumental]                                      [Paragraphe 1 — 2 à 3 phrases]                  |
|       [3 à 4 lignes, révélées ligne par ligne]                       [Paragraphe 2 — 2 à 3 phrases]                  |
|       [espace généreux]                                              [Paragraphe 3 — facultatif]                     |
|                                                                                                                      |
|       +--------------------------------------------------------------------------------------------------------+     |
|       | [phrase d'ancrage en italique serif, pleine largeur : une entreprise n'est pas séparée de la personne] |     |
|       +--------------------------------------------------------------------------------------------------------+     |
|                                                                                                                      |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|                                                                      |
|   [H2 éditorial monumental, 3 à 4 lignes]                            |
|                                                                      |
|   [Paragraphe 1 — colonne de lecture, 620 px maximum]                |
|   [Paragraphe 2]                                                     |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |              [phrase d'ancrage en italique serif]              | |
|   +----------------------------------------------------------------+ |
|                                                                      |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|                                    |
|  [H2 éditorial]                    |
|  [3 à 5 lignes]                    |
|                                    |
|  [Paragraphe 1]                    |
|  [Paragraphe 2]                    |
|                                    |
|  +--------------------------------+|
|  |       [phrase d'ancrage]       ||
|  +--------------------------------+|
|                                    |
+------------------------------------+

Contrat : colonne de texte ≤ 70 caractères par ligne ; fond clair ; révélations ligne par ligne (§8) ; aucun élément interactif dans la section.

4.4.5 Méthode — Boussole de clarté (bloc D) #

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|                                                                                                                      |
|       [H2 « Boussole de clarté »]                                                                                    |
|       [introduction courte]                                                                                          |
|                                                                                                                      |
|                       (1 Écouter)                                +----------------------------------------------+    |
|                  .-'                  '-.                        |          Panneau de l'étape active           |    |
|             (4 Décider)    Boussole    (2 Explorer)              |              [titre de l'étape]              |    |
|                             de clarté                            |         [explication — 3 à 5 lignes]         |    |
|                  '-.                  .-'                        |  [ce que Malka fait] / [ce que vous faites]  |    |
|                       (3 Clarifier)                              +----------------------------------------------+    |
|          boussole à gauche (colonnes 1 à 6), nœuds 56 px,                                                            |
|          panneau à droite (colonnes 7 à 12) ; 1 → 2 → 3 → 4 dans le sens horaire                                     |
|                                                                                                                      |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|                                                                      |
|   [H2 « Boussole de clarté »]                                        |
|   [introduction courte]                                              |
|                                                                      |
|                        (1 Écouter)                                   |
|            (4 Décider)     Boussole        (2 Explorer)              |
|                            de clarté                                 |
|                        (3 Clarifier)                                 |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |       Panneau de l'étape active — [titre] [explication]        | |
|   +----------------------------------------------------------------+ |
|                                                                      |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|                                    |
|  [H2 « Boussole de clarté »]       |
|  [introduction courte]             |
|                                    |
|             (1 Écouter)            |
|  (4 Décider)  Boussole (2 Explorer)|
|               de clarté            |
|             (3 Clarifier)          |
|                                    |
|  +--------------------------------+|
|  |   Panneau de l'étape active    ||
|  |     [titre] [explication]      ||
|  +--------------------------------+|
|                                    |
+------------------------------------+

Contrat : titre de la section « Boussole de clarté » ; fond sombre forêt ; première étape (Écouter) active par défaut (le panneau n'est jamais vide) ; chaque nœud mesure 56 px (48 px sous 768 px) ; les nœuds sont des onglets (role="tab") à activation automatique : les flèches déplacent le focus et activent l'étape ; sur pointeur fin, le survol prévisualise l'étape et le clic la verrouille ; au toucher, l'activation se fait par toucher ; les quatre panneaux restent montés dans une même cellule de grille (seul le panneau actif est visible, les autres sont masqués et inert) ; les repères methode-<id> sont des <span> vides de hauteur nulle (§4.2.1) ; le panneau est placé sous la boussole en dessous de 1024 px et à droite de la boussole à partir de 1024 px (boussole aux colonnes 1 à 6, panneau aux colonnes 7 à 12) ; sans JavaScript, les quatre étapes sont listées l'une sous l'autre.

4.4.6 Accompagnements (bloc E) #

Présentation éditoriale : un panneau sombre pour l'offre mise en avant et des lignes éditoriales sans cadre pour les autres (composant OfferItem, variantes panel et row) ; ni grille de cartes, ni hauteurs égales imposées.

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|                                                                                                                      |
|      [H2 « Accompagnements »]                    [introduction courte]                                               |
|                                                                                                                      |
|      +---------------------------------+         02  Lecture globale de l'entrepreneur et de l'entreprise            |
|      | 01  PANNEAU SOMBRE              |             [description courte] · À distance                               |
|      | [mise en avant]                 |             ▸ Détails (élément natif <details>)                             |
|      | Séance de clarification ciblée  |             Tarif 150 €   (Écrire sur WhatsApp)  Écrire via le formulaire › |
|      | [description courte]            |             - - - - - - - - - - - - - - - - - - - - - - - - - - - - -       |
|      | À distance                      |         03  Suivi de transition sur six mois                                |
|      | ▸ Détails (<details>)           |             [description courte] · À distance                               |
|      |                                 |             ▸ Détails                                                       |
|      | Tarif 300 €                     |             Tarif 1 000 € (Écrire sur WhatsApp)  Écrire via le formulaire › |
|      | (Écrire sur WhatsApp)           |             - - - - - - - - - - - - - - - - - - - - - - - - - - - - -       |
|      | Écrire via le formulaire ›      |         04  Accompagnement personnalisé                                     |
|      +---------------------------------+             [description courte] · À distance                               |
|                                                      Sur devis     (Écrire sur WhatsApp)  Écrire via le formulaire › |
|                                                                                                                      |
|      [mention de TVA — lue dans legal.ts, affichée sous la liste]                                                    |
|                                                                                                                      |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|                                                                      |
|   [H2 « Accompagnements »]                                           |
|   [introduction courte]                                              |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   | PANNEAU SOMBRE — 01  Séance de clarification ciblée            | |
|   | [mise en avant] · [description] · À distance                   | |
|   | ▸ Détails       Tarif 300 €                                    | |
|   | (Écrire sur WhatsApp)   Écrire via le formulaire ›             | |
|   +----------------------------------------------------------------+ |
|                                                                      |
|   02  Lecture globale de l'entrepreneur et de l'entreprise           |
|       [description] · À distance    Tarif 150 €                      |
|       (Écrire sur WhatsApp)   Écrire via le formulaire ›             |
|   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -   |
|   (lignes 03 « Suivi de transition sur six mois » — Tarif 1 000 €    |
|   et 04 « Accompagnement personnalisé » — Sur devis : même structure)|
|                                                                      |
|   [mention de TVA]                                                   |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|                                    |
|  [H2 « Accompagnements »]          |
|  [introduction courte]             |
|                                    |
|  +--------------------------------+|
|  | PANNEAU SOMBRE                 ||
|  | 01 Séance de clarification     ||
|  |    ciblée     [mise en avant]  ||
|  | [description] · À distance     ||
|  | ▸ Détails      Tarif 300 €     ||
|  | (   Écrire sur WhatsApp   )    ||
|  | (Écrire via le formulaire ›)   ||
|  +--------------------------------+|
|                                    |
|  02 Lecture globale de             |
|     l'entrepreneur et de           |
|     l'entreprise                   |
|     [description] · À distance     |
|     Tarif 150 €                    |
|     (   Écrire sur WhatsApp   )    |
|     (Écrire via le formulaire ›)   |
|  - - - - - - - - - - - - - - - -   |
|  (lignes 03 et 04 : même structure,|
|  empilées dans l'ordre 1 → 4)      |
|                                    |
|  [mention de TVA]                  |
+------------------------------------+

Contrat : ordre du DOM = ordre order de offers.ts ; l'offre mise en avant se distingue visuellement (panneau sombre, dimension, ombre) sans changer sa place dans le DOM ; chaque offre expose titre (h3), description, format (« À distance »), tarif (le mot « Tarif » toujours visible + montant, ou « Sur devis »), détails dans un élément natif <details>, CTA WhatsApp « Écrire sur WhatsApp » (principal) et lien « Écrire via le formulaire » (secondaire, href="/?offre=<id>#contact") ; la liste supporte de 1 à 6 offres sans casse (une offre seule : panneau large unique) ; aucune hauteur égale n'est forcée entre offres voisines.

4.4.7 Boussole d'orientation (bloc F) #

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|                                                                                                                      |
|       [H2 « Boussole d'orientation »]                        +--------------------------------------------------+    |
|       [introduction — une piste, pas un diagnostic]          |         Résultat de la situation choisie         |    |
|       +---------------------+  +---------------------+       |       [piste de réflexion — 2 à 4 phrases]       |    |
|       | ( ) Décision bloquée|  | ( ) Fatigue         |       |     [question 1]  [question 2]  [question 3]     |    |
|       +---------------------+  +---------------------+       |   Offre possible : [titre de l'offre suggérée]   |    |
|       +---------------------+  +---------------------+       | (Écrire sur WhatsApp)  (Écrire via le formulaire)|    |
|       | ( ) Repositionnement|  | ( ) Transition      |       |  Cette boussole propose une piste de réflexion…  |    |
|       +---------------------+  +---------------------+       +--------------------------------------------------+    |
|                                                                                                                      |
|       2 × 2 à gauche (colonnes 1 à 5), résultat à droite ; puces de 56 px de haut, cases radio natives               |
|       état initial (aucun choix) : panneau de droite = invitation à choisir une situation + mise en garde            |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|                                                                      |
|   [H2 « Boussole d'orientation »]                                    |
|   [introduction]                                                     |
|                                                                      |
|   +------------------------------+  +------------------------------+ |
|   |       Décision bloquée       |  |           Fatigue            | |
|   +------------------------------+  +------------------------------+ |
|   +------------------------------+  +------------------------------+ |
|   |       Repositionnement       |  |          Transition          | |
|   +------------------------------+  +------------------------------+ |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |    Résultat : [piste] [questions] Offre possible : [titre]     | |
|   |     (Écrire sur WhatsApp)   (Écrire via le formulaire)         | |
|   |         Cette boussole propose une piste de réflexion…         | |
|   +----------------------------------------------------------------+ |
|                                                                      |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|                                    |
|  [H2 « Boussole d'orientation »]   |
|  [introduction]                    |
|                                    |
|  |      ( ) Décision bloquée      ||
|  |          ( ) Fatigue           ||
|  |      ( ) Repositionnement      ||
|  |         ( ) Transition         ||
|                                    |
|  +--------------------------------+|
|  |Résultat : [piste de réflexion] ||
|  |  [questions] Offre : [titre]   ||
|  |    (Écrire sur WhatsApp)       ||
|  |  (Écrire via le formulaire)    ||
|  | Mise en garde (non diagnostic) ||
|  +--------------------------------+|
|                                    |
+------------------------------------+

Contrat : quatre cases radio natives dans un radiogroup (fieldset et legend), rendues côté serveur ; chaque puce mesure 56 px de haut ; disposition 2 × 2 à partir de 1024 px et 2 × 2 en tablette, pile d'une colonne sous 768 px ; aucune situation présélectionnée (état initial vide avec invitation) ; le résultat remplace l'invitation sans décalage de mise en page (le panneau réserve sa hauteur minimale) ; deux CTA dans le résultat : « Écrire sur WhatsApp » et « Écrire via le formulaire » ; l'état est local (§6.6) ; la mise en garde est dans le panneau, toujours visible avec un résultat ; le panneau défile dans la vue uniquement après une sélection au pointeur ou au toucher ; avec @media (scripting: none), une liste statique remplace le sélecteur.

4.4.8 À propos de Malka (bloc G) #

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|                                                                                                                      |
|       +---------------------------------------+          [H2 « À propos »]                                           |
|       |                                       |          [Présentation personnelle — paragraphes validés par Malka]  |
|       |       PORTRAIT RÉEL — ratio 4:5       |          [Posture professionnelle — 2 à 3 points]                    |
|       |             (≈ 492 × 615)             |          [Approche humaine — paragraphe]                             |
|       |                                       |                                                                      |
|       +---------------------------------------+                                                                      |
|                                                                                                                      |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|                                                                      |
|   +----------------------------+   [H2 « À propos »]                 |
|   |     PORTRAIT RÉEL 4:5      |   [Présentation personnelle]        |
|   |                            |   [Posture professionnelle]         |
|   +----------------------------+   [Approche humaine]                |
|                                                                      |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|                                    |
|  [H2 « À propos »]                 |
|  +--------------------------------+|
|  |       PORTRAIT RÉEL 4:5        ||
|  |                                ||
|  +--------------------------------+|
|  [Présentation personnelle]        |
|  [Posture professionnelle]         |
|  [Approche humaine]                |
|                                    |
+------------------------------------+

Contrat : le portrait est une photographie réelle (jamais une image de repli en production) ; ratio fixe 4:5 avec dimensions déclarées (aucun décalage) ; texte alternatif descriptif ; le portrait précède le texte dans le DOM sur mobile ; l'éventuelle mention de droits de la photo figure dans les crédits des pages légales, pas dans cette section ; aucune certification, aucun diplôme ni chiffre non fourni par Malka.

4.4.9 Témoignages (bloc H, conditionnel) #

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|                                                                                                                      |
|       [H2 « Témoignages »]                                                                                           |
|                                                                                                                      |
|              +------------------------------------------------------------------------------------------+            |
|              |    « [texte du témoignage, mot pour mot, mis en valeur en grande typographie serif] »    |            |
|              |                                                                                          |            |
|              |                              [Nom de l'auteur] — [contexte]                              |            |
|              +------------------------------------------------------------------------------------------+            |
|                                                                                                                      |
|              (‹)  (›)      ● ○      1 / 2                                                                            |
|                                                                                                                      |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|                                                                      |
|   [H2 « Témoignages »]                                               |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |                   « [texte du témoignage] »                    | |
|   |                       [Nom] — [contexte]                       | |
|   +----------------------------------------------------------------+ |
|                                                                      |
|   (‹)  (›)      ● ○      1 / 2                                       |
|                                                                      |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|                                    |
|  [H2 « Témoignages »]              |
|                                    |
|  +--------------------------------+|
|  |   « [texte du témoignage] »    ||
|  |       [Nom] — [contexte]       ||
|  +--------------------------------+|
|                                    |
|  (‹)  (›)    ● ○    1 / 2          |
|                                    |
+------------------------------------+

Contrat : section et lien présents seulement s'il existe au moins un témoignage publiable (publié, consentement accordé, citation non vide de 700 caractères au plus) ; le carrousel n'existe qu'à partir de deux témoignages — avec un seul, la citation est affichée seule, sans flèches ni indicateurs ; les guillemets « » sont ajoutés par le composant autour du texte, qui reste mot pour mot celui de la personne ; aucune photo, aucun avatar ; pas de défilement automatique ; navigation en boucle (les boutons ne sont jamais désactivés) ; boutons précédent et suivant de 48 px ; indicateurs de 10 px dotés d'une zone cliquable de 44 px ; tous les panneaux restent montés dans une même cellule de grille, la hauteur du bloc est donc celle du témoignage le plus long (aucun saut de hauteur au changement) ; transitions en fondu avec un léger décalage vertical, instantanées en mouvement réduit ; sans JavaScript, tous les témoignages sont listés l'un sous l'autre.

4.4.10 Contact et prise de rendez-vous (bloc I) #

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|                                                                                                                      |
|       [H2 « Contact »]                                     +----------------------------------------------------+    |
|       [introduction — un échange simple et sans engagement]|                       Nom *                        |    |
|       +--------------------------------------------+       |                      E-mail *                      |    |
|       |           (Écrire sur WhatsApp)            |       |               Téléphone (facultatif)               |    |
|       |               06 67 29 51 79               |       |        Offre concernée (liste, facultatif)         |    |
|       |                                            |       |        Votre situation (liste, facultatif)         |    |
|       |         Téléphone : 06 67 29 51 79         |       |         Message *  (20 à 2000 caractères)          |    |
|       |      E-mail : astheoris.pro@gmail.com      |       |     [ ] consentement (lien vers la politique)      |    |
|       |     France — accompagnement à distance     |       |                (Envoyer ma demande)                |    |
|       +--------------------------------------------+       |     zone d'état : chargement / succès / erreur     |    |
|                                                            +----------------------------------------------------+    |
|                                                                                                                      |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|                                                                      |
|   [H2 « Contact »]                                                   |
|   [introduction]                                                     |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |             (Écrire sur WhatsApp)   06 67 29 51 79             | |
|   |            Téléphone — E-mail — France, à distance             | |
|   +----------------------------------------------------------------+ |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |                      Nom *   —   E-mail *                      | |
|   |          Téléphone (facultatif)   —   Offre concernée          | |
|   |              Votre situation (facultatif)                      | |
|   |                           Message *                            | |
|   |       [ ] consentement     (Envoyer ma demande)                | |
|   |                          zone d'état                           | |
|   +----------------------------------------------------------------+ |
|                                                                      |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|                                    |
|  [H2 « Contact »]                  |
|  [introduction]                    |
|                                    |
|  +--------------------------------+|
|  |      Écrire sur WhatsApp       ||
|  |         06 67 29 51 79         ||
|  +--------------------------------+|
|  Téléphone · E-mail · France       |
|                                    |
|  +--------------------------------+|
|  |             Nom *              ||
|  |            E-mail *            ||
|  |     Téléphone (facultatif)     ||
|  |        Offre concernée         ||
|  |  Votre situation (facultatif)  ||
|  |           Message *            ||
|  |        [ ] consentement        ||
|  |      Envoyer ma demande        ||
|  |          zone d'état           ||
|  +--------------------------------+|
|                                    |
+------------------------------------+

Contrat : le bloc WhatsApp précède toujours le formulaire dans le DOM et à l'écran ; sur ordinateur il est à gauche, sur tablette et mobile il est au-dessus ; champs en pleine largeur de leur colonne, hauteur ≥ 48 px, étiquettes visibles (pas de placeholder seul) ; deux listes facultatives visibles et modifiables, « Offre concernée » et « Votre situation (facultatif) », peuvent être présélectionnées par un parcours (offre ou boussole) ; le champ « Message » n'est jamais prérempli ; pendant l'envoi, le formulaire porte aria-busy, les champs sont en lecture seule et le bouton « Envoyer ma demande » est aria-disabled ; le champ piège n'est ni visible ni focalisable ni lu par les lecteurs d'écran ; la zone d'état (aria-live) réserve sa hauteur pour éviter tout décalage ; au moins 64 px d'espace libre sont réservés en bas de la section pour la carte flottante du footer (§4.4.11).

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|   fin de #contact (fond clair) — au moins 64 px d'espace libre réservés sous son dernier contenu                     |
|                                                                                                                      |
|                              +----------------------------------------------------------+                            |
|                              |   [titre d'invitation — <p> en police d'affichage]       |                            |
|- - - - - - - - - - - - - - - |      [texte court, sans promesse de résultat]            | - - - jonction Contact/Footer|
|                              |   (Écrire sur WhatsApp)    Écrire via le formulaire ›    |   chevauchement :          |
|                              +----------------------------------------------------------+   --footer-overlap (80 px) |
|                                                                                                                      |
|   ~~ silhouettes de montagnes + rivière lumineuse (SVG vectoriel, décor) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
|                                                                                                                      |
|       Navigation                           Contact                               Informations                        |
|       Philosophie · Méthode · …            06 67 29 51 79                        Mentions légales                    |
|       (un lien par ligne)                  astheoris.pro@gmail.com               Politique de confidentialité        |
|                                            France — accompagnement à distance    Retour en haut ↑                    |
|                                                                                                                      |
|       © [année] Malka Debleds                                                                                        |
|  C   L   A   R   T   É   (discret, géant, rogné par le bas, opacité 0,08)                                            |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|   fin de #contact — ≥ 64 px d'espace libre réservés                  |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |  [titre d'invitation — <p>]                                    | |
| - |- - - jonction Contact / Footer (chevauchement 64 px) - - - - - | |
|   |  (Écrire sur WhatsApp)       Écrire via le formulaire ›        | |
|   +----------------------------------------------------------------+ |
|   ~~ montagnes + rivière lumineuse (décor vectoriel) ~~              |
|                                                                      |
|   Navigation                        Contact                          |
|   Philosophie · Méthode…            06 67 29 51 79                   |
|   (un lien par ligne)               astheoris.pro@gmail.com          |
|   Informations                      France — à distance              |
|   Mentions légales, Confidentialité                                  |
|                                                                      |
|   © [année] Malka Debleds                                            |
|   C  L  A  R  T  É  (discret, rogné)                                 |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|  fin de #contact (≥ 64 px libres)  |
|  +--------------------------------+|
|  |[titre d'invitation — <p>]      ||
| - - jonction Contact / Footer - - -|
|  |  (  Écrire sur WhatsApp  )     ||
|  |  Écrire via le formulaire ›    ||
|  +--------------------------------+|
|  ~~ montagnes + rivière (décor) ~~ |
|                                    |
|  Navigation                        |
|  Philosophie · Méthode · …         |
|  Contact                           |
|  06 67 29 51 79                    |
|  astheoris.pro@gmail.com           |
|  France — accompagnement à distance|
|  Mentions légales                  |
|  Politique de confidentialité      |
|  Retour en haut ↑                  |
|                                    |
|  © [année] Malka Debleds           |
|  CLARTÉ (décor rogné, 0,08)        |
|                                    |
+------------------------------------+

Contrat : le footer est sombre et entièrement vectoriel (montagnes et rivière en SVG, sans image raster) ; la carte CTA est le composant unique FloatingCtaCard : elle est élevée visuellement (ombre, bordure champagne), en position: relative (jamais fixe ni collante) et chevauche la jonction Contact → Footer de --footer-overlap (48 px, 64 px puis 80 px selon la largeur, jeton de §5.11) ; au moins 64 px d'espace libre sont réservés en bas de #contact, de sorte qu'aucun texte ni aucune cible de Contact ne se trouve sous la carte et qu'aucun texte ne se superpose ; son titre est un <p> stylé en police d'affichage (pas un titre de plan) ; bouton principal « Écrire sur WhatsApp » et lien secondaire « Écrire via le formulaire » (cible #contact ; l'événement d'analytique des deux est footer_cta) ; liens d'une colonne séparés d'au moins 8 px et de hauteur ≥ 44 px au toucher ; le mot CLARTÉ est décoratif, statique (sans parallaxe), derrière tout le texte, en ivory-50 à l'opacité 0,08, font-size: clamp(6rem, 22vw, 20rem), sans effet sur la lecture ; padding bas = max(32px, env(safe-area-inset-bottom)). Le lien « Témoignages » du footer suit la même règle conditionnelle que celui du header.

4.4.12 Pages légales et pages système (404, erreur) #

Ordinateur — 1440 px #
+----------------------------------------------------------------------------------------------------------------------+
|   [Malka Debleds]   Philosophie Méthode Accompagnements Orientation À propos Contact               (Faire le point)  |
|   header à l'état « solid » (fond plein dès le chargement) ; liens vers /#<ancre>, aucun lien actif                  |
|                              [H1 « Mentions légales » ou « Politique de confidentialité »]                           |
|                              Dernière mise à jour : [date de legal.ts]                                               |
|                                                                                                                      |
|                              +----------------------------------------------------------+                            |
|                              |[contenu structuré en titres h2 / h3 — colonne de lecture, 72 caractères max.]|        |
|                              |                                                          |                            |
|                              +----------------------------------------------------------+                            |
|                                                                                                                      |
|   [footer identique à la page d'accueil, sans carte CTA]                                                             |
+----------------------------------------------------------------------------------------------------------------------+
Tablette — 834 px #
+----------------------------------------------------------------------+
|   [Malka Debleds]                           (Faire le point)   [≡]   |
|                                                                      |
|   [H1 de la page]                                                    |
|   Dernière mise à jour : [date]                                      |
|                                                                      |
|   +----------------------------------------------------------------+ |
|   |           [contenu — colonne de lecture 620 px max.]           | |
|   +----------------------------------------------------------------+ |
|                                                                      |
|   [footer]                                                           |
+----------------------------------------------------------------------+
Mobile — 390 px #
+------------------------------------+
|  [Malka Debleds]               [≡] |
|                                    |
|  [H1 de la page]                   |
|  Dernière mise à jour : [date]     |
|                                    |
|  [contenu — pleine largeur]        |
|  [titres h2 / h3]                  |
|                                    |
|  [footer]                          |
+------------------------------------+

Page 404 et page d'erreur (valable aux trois largeurs, spécification complète en §6.11) : un seul bloc centré, avec h1, une phrase d'explication, le bouton « Retour à l'accueil » vers /, et les moyens de contact (WhatsApp, e-mail). En développement et préproduction, les pages légales affichent en tête un bandeau « Informations légales à fournir par Malka avant mise en production » aux endroits où une valeur manque ; ce bandeau n'existe pas en production (garde G-01).

4.5 Empilement, couches et règles anti-chevauchement #

Principe : aucun chevauchement parasite, aucun texte illisible, aucun faux bouton, aucun élément décoratif bloquant. Les superpositions voulues (portrait devant le mot CLARTÉ, calques de paysage, carte flottante du footer sur la jonction Contact → Footer) sont des relations entre éléments décoratifs, entre un décor et rien d'autre, ou entre une carte et une zone réservée vide ; elles ne concernent jamais un texte porteur d'information ni une cible interactive.

4.5.1 Échelle d'empilement globale (z-index nommés) #

Toutes les valeurs z-index viennent de variables CSS nommées déclarées une fois dans src/styles/globals.css (leur définition de référence est en §5.7.4) ; aucune valeur numérique brute n'est autorisée dans un composant (règle de lint, §12).

Couche Variable Valeur Contenu Remarques
Fond de page --z-base 0 Fonds de section, flux normal Valeur par défaut
Contenu --z-content 1 Textes, cartes, formulaires au-dessus des décors locaux Chaque section crée son contexte avec isolation: isolate ; le footer, qui porte la carte flottante, est au même rang pour que la carte recouvre la fin de Contact
Header --z-header 40 En-tête fixe et bouton de menu Toujours visible
Menu mobile --z-menu 50 Panneau plein écran du menu, avec sa propre barre (logo et bouton de fermeture) Existe seulement sous 1280 px ; recouvre le header
Lien d'évitement --z-skip 60 « Aller au contenu » au focus Au-dessus de tout

Hormis le panneau de menu mobile, il n'existe aucune couche pour fenêtre modale, toast, bannière de cookies ni bouton flottant : ces éléments n'existent pas dans le produit.

4.5.2 Échelles locales (dans un contexte isolation: isolate) #

Les valeurs ci-dessous sont celles des variables nommées de §5.7.4 ; le code n'emploie que les noms.

Contexte Variable Valeur Calque
Hero (#accueil) --z-hero-far 0 Ciel et sommets (calque lointain)
--z-hero-mid 1 Vallée et brume
--z-hero-scrim 2 Voile de lisibilité --scrim-hero-x (§4.5.3)
--z-hero-word 3 Mot CLARTÉ
--z-hero-halo 4 Halo champagne derrière le portrait
--z-hero-portrait 5 Portrait
--z-hero-near 6 Crête proche, devant le pied du portrait
--z-hero-veil 7 Voile de la séquence d'entrée (§8)
--z-hero-grain 8 Grain léger
--z-hero-content 10 Colonne de texte, CTA, chiffres de confiance, rail
--z-hero-edge 11 Dégradé de transition vers la section suivante (aucun contenu, pointer-events: none)

Les sections Méthode (#methode) et Footer n'ont pas d'échelle locale nommée : leurs décors (halos et brume de fond ; ciel, montagnes, rivière lumineuse et mot CLARTÉ discret) utilisent --z-base et leur contenu (boussole et panneau ; carte flottante, colonnes, liens) utilise --z-content, dans un contexte isolation: isolate, l'ordre des décors entre eux étant donné par l'ordre du DOM.

Ordre de lecture des calques du hero, du plus bas au plus haut : lointain < brume < voile < mot < halo < portrait < crête proche < voile d'entrée < grain < contenu < bord de transition. Le voile est donc sous le mot, le halo et le portrait (le visage n'est jamais assombri) et au-dessus du paysage.

Règle : un décor local n'a jamais un z-index supérieur à celui du contenu de son contexte, à l'exception du bord de transition du hero (--z-hero-edge), qui ne contient ni texte ni cible interactive.

4.5.3 Texte sur image : voile et contraste #

  1. Aucun texte porteur d'information n'est placé sur une zone d'image dont le contraste n'est pas garanti ≥ 4,5:1 (aussi pour le grand texte : on retient le seuil le plus strict).
  2. Sur mobile et tablette, aucun texte n'est placé sur une image (D-20) : le voile n'est pas nécessaire.
  3. Sur ordinateur, la colonne de texte du hero repose sur le voile --scrim-hero-x (valeurs de forest-950, définition de référence en §5.4.1) ; l'opacité reste au moins égale à 0,80 jusqu'à 60 % de la largeur, ce qui couvre le bord droit de la colonne de texte à toutes les largeurs de 1024 à 1920 px :
/* voile de lisibilité du hero — au-dessus du paysage, sous le mot CLARTÉ */
.hero-scrim {
  position: absolute;
  inset: 0;
  z-index: var(--z-hero-scrim);
  pointer-events: none;
  background: var(--scrim-hero-x);
}

/* définition du jeton (référence : §5.4.1) */
:root {
  --scrim-hero-x: linear-gradient(
    90deg,
    rgb(14 34 25 / 0.94) 0%,
    rgb(14 34 25 / 0.86) 25%,
    rgb(14 34 25 / 0.80) 60%,
    rgb(14 34 25 / 0.36) 72%,
    rgb(14 34 25 / 0) 88%
  );
}
  1. Le mot CLARTÉ est de couleur champagne-200 à plat, d'opacité 0,14 (hero) et, dans le footer, ivory-50 d'opacité 0,08 : texte ivory-50 posé sur la zone la plus claire possible du mot (forêt forest-900 mélangée à 14 % de champagne) conserve un rapport de contraste supérieur à 10:1. C'est cette garantie qui autorise le mot à passer sous la colonne de texte.
  2. Les chiffres de confiance et le texte du footer ne reposent que sur des fonds unis forêt ou sur un voile de même nature. Les entrées du rail de méthode, placées sur le paysage, reposent sur leur capsule forest-950 d'opacité 0,80, jamais directement sur l'image.
  3. Méthode de vérification automatique : pour chaque bloc de texte du hero, capturer la page avec le texte rendu transparent, échantillonner les pixels de la boîte du texte, prendre le pixel le plus clair (pire cas pour un texte clair) et calculer le rapport avec la couleur du texte ; échec en dessous de 4,5:1 (test en §15).

4.5.4 Règles anti-chevauchement et dégagements #

ID Règle
AC-4.5-01 Les boîtes de deux éléments marqués data-layout="text", "media" ou "control" d'une même section ne s'intersectent jamais (hors imbrication parent-enfant)
AC-4.5-02 Dégagement minimal entre un bloc de texte et un autre élément de contenu : 24 px à partir de 1024 px, 16 px en dessous
AC-4.5-03 Écart minimal entre deux cibles interactives adjacentes : 8 px, mesuré entre zones cliquables étendues comprises (les zones de 44 px des indicateurs du carrousel ne se chevauchent pas) ; chaque cible ≥ 24 × 24 px (≥ 44 × 44 px pour les cibles tactiles principales : CTA, menu, rail, boussoles, contrôles du carrousel, bouton d'envoi)
AC-4.5-04 Tout élément décoratif porte aria-hidden="true" (ou n'existe que par CSS), pointer-events: none, ne reçoit jamais le focus et reste confiné à sa section par overflow: clip
AC-4.5-05 Aucun élément n'est fixe (position: fixed) en dehors du header, du panneau de menu mobile et du lien d'évitement. Pas de barre collante en bas d'écran, pas de bouton WhatsApp flottant
AC-4.5-06 Hauteurs plein écran exprimées en svh (repli vh) pour éviter les sauts dus aux barres des navigateurs mobiles ; le panneau de menu mobile utilise dvh
AC-4.5-07 Zones sûres : <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> ; marges horizontales = max(gouttière, env(safe-area-inset-left/right)) ; padding bas du footer et du panneau de menu = max(32px, env(safe-area-inset-bottom))
AC-4.5-08 Aucun défilement horizontal à 320 px de large (aucun élément ne déborde du conteneur) ; overflow-x: clip au niveau du body en protection, jamais en remplacement d'une correction de mise en page
AC-4.5-09 Le focus clavier n'est jamais masqué par le header : scroll-padding-top et scroll-margin-top = hauteur du header + 16 px
AC-4.5-10 Aucun anneau de focus n'est rogné : un conteneur avec overflow: clip laisse au moins 4 px de marge intérieure autour de ses éléments focalisables, ou l'anneau est dessiné à l'intérieur
AC-4.5-11 Toute image porte width et height (ou aspect-ratio) ; tout texte animé occupe une boîte de dimensions déjà réservées : zéro décalage (CLS ≤ 0,05)
AC-4.5-12 Dans le hero (≥ 1024 px) : bord droit du bloc titre + 24 px ≤ bord gauche du portrait ; portrait ↔ rail ≥ 24 px ; CTA ↔ chiffres de confiance ≥ 24 px ; le mot CLARTÉ est le seul élément qui peut se superposer à un texte (§4.5.3)
AC-4.5-13 La carte flottante du footer a position: relative (ni fixed ni sticky) ; elle chevauche la jonction Contact → Footer d'exactement --footer-overlap ; au moins 64 px d'espace libre restent en bas de #contact ; aucune intersection avec un élément data-layout de #contact ni avec les colonnes du footer, et aucune collision de texte
AC-4.5-14 Les bords en dégradé et les dégradés de transition ne contiennent ni texte ni cible interactive et sont inclus dans le padding vertical de la section (§4.1.3)
AC-4.5-15 Les éléments animés ne changent jamais leur boîte de mise en page : seules transform, opacity, filter et clip-path varient (§8)

4.5.5 Test automatisé de non-chevauchement #

Les composants marquent leurs éléments avec data-layout="text" | "media" | "control" | "decor". Dans le hero, un attribut data-hero-box (headline, portrait, rail, etc.) est un alias nommé de la même boîte, posé en plus de data-layout. Le test E2E (§15) parcourt chaque section et vérifie, pour chaque viewport de §4.6.4, qu'aucune paire d'éléments text, media ou control ne s'intersecte et que les dégagements de AC-4.5-02 sont respectés.

// tests/e2e/layout-overlap.spec.ts — logique de référence
const kinds = '[data-layout="text"], [data-layout="media"], [data-layout="control"]';
for (const section of await page.locator('main > section').all()) {
  const boxes = await section.locator(kinds).evaluateAll((els) =>
    els.map((el) => ({ id: el.getAttribute('data-testid') ?? el.tagName, r: el.getBoundingClientRect() })),
  );
  // paires non imbriquées : aucune intersection, dégagement minimal respecté
  expect(findIntersections(boxes, { minGapPx: viewportWidth >= 1024 ? 24 : 16 })).toEqual([]);
}

4.6 Règles responsives #

4.6.1 Plages de largeur #

Les points de rupture sont ceux de Tailwind par défaut : sm 640, md 768, lg 1024, xl 1280, 2xl 1536. Mobile < 768 px ; tablette 768–1023 px ; ordinateur ≥ 1024 px. Largeur minimale supportée : 320 px.

Plage Gouttières Header Navigation Hero Parallaxe et effets
320–639 px 24 px 64 px ; logo + bouton de menu Menu plein écran ; CTA dans le panneau Pile verticale (D-20) ; CTA pleine largeur ; rail 4 colonnes en 12 px Aucune parallaxe ni flou ; aucune boucle atmosphérique
640–767 px 24 px 64 px ; logo + CTA + menu Menu plein écran Pile verticale ; CTA côte à côte ; bande 9:10 Idem
768–1023 px 40 px 64 px ; logo + CTA + menu Menu plein écran Pile verticale ; bande 16:10 Idem
1024–1279 px 64 px 72 px ; logo + CTA + menu Menu plein écran ; liens du header dans le panneau Deux colonnes 7fr / 5fr (portrait ≈ 363–470 px, ratio 4:5) ; rail en rangée horizontale sous les chiffres de confiance ; hauteur clamp(720px, 100svh, 1080px) Parallaxe légère si pointeur fin et mouvement non réduit : translation maximale 120 px (calque lointain), 64 px (brume), 48 px (mot), 24 px (portrait), 0 px (crête proche et texte) ; aucune parallaxe au pointeur
1280–1535 px 64 px 72 px ; liens en ligne Liens en ligne ; mise en évidence active Composition complète ; rail vertical dans la gouttière droite ; portrait ≈ 470–523 px Idem
≥ 1536 px (jusqu'à 1920 et 2560) 64 px, conteneur centré à 1280 px 72 px Idem Composition complète ; portrait ≈ 523 px (colonne 5fr) ; paysage plein écran Idem

4.6.2 Comportement par section #

Section < 768 px 768–1023 px ≥ 1024 px
Philosophie 1 colonne 1 colonne, texte 620 px max 2 colonnes asymétriques (titre à gauche, texte à droite)
Méthode Boussole puis panneau ; nœuds de 48 px Boussole centrée puis panneau ; nœuds de 56 px Boussole à gauche (colonnes 1 à 6), panneau à droite (colonnes 7 à 12) ; nœuds de 56 px
Accompagnements Panneau sombre de l'offre mise en avant puis 3 lignes éditoriales empilées Panneau en pleine largeur puis lignes en colonne Panneau à gauche, lignes éditoriales à droite
Orientation 4 choix empilés (puces de 56 px) puis résultat Choix 2 × 2 puis résultat Choix 2 × 2 à gauche, résultat à droite
À propos Portrait puis texte Portrait et texte côte à côte Portrait à gauche, texte à droite
Témoignages 1 témoignage à la fois, contrôles dessous (carrousel à partir de 2 témoignages) Idem Citation centrée sur 92 caractères de large, contrôles dessous
Contact WhatsApp, puis formulaire WhatsApp, puis formulaire WhatsApp à gauche, formulaire à droite
Footer 1 colonne ; carte flottante pleine largeur, chevauchement de 48 px Carte flottante, 2 colonnes ; chevauchement de 64 px Carte flottante, 3 colonnes ; chevauchement de 80 px

4.6.3 Règles transversales #

  1. Typographie : tailles fluides par clamp() définies en §5 ; à 320 px, le plus long mot du titre du hero (« entreprise ») ne déborde pas ; aucun titre n'utilise de hyphens forcés.
  2. Zoom et texte agrandi : à 200 % de zoom et à 400 % (équivalent 320 px), tout reste lisible, sans défilement horizontal ni perte de fonction ; si l'utilisateur augmente la taille de police du navigateur de 200 %, aucun texte n'est tronqué.
  3. Orientation paysage sur téléphone (par exemple 844 × 390, largeur ≥ 768 px mais hauteur < 500 px) : mise en page tablette, hero sans hauteur forcée, header 64 px.
  4. Très grands écrans : le conteneur reste à 1280 px ; les fonds (paysage, footer) s'étendent sur toute la largeur avec object-fit: cover ; la hauteur du hero est plafonnée à 1080 px ; le contenu est centré ; le rail du hero reste aligné sur le bord droit du conteneur, jamais sur le bord de la fenêtre.
  5. Images : les calques de parallaxe haute définition ne sont rendus qu'à partir de 1024 px ; en dessous, seule l'image aplatie du hero (hero-mobile, object-fit: cover) est affichée, dans la bande encadrée (poids mobile ≤ 350 Ko au-dessus de la ligne de flottaison, §11).
  6. Pointeur : le survol n'est jamais la seule manière d'accéder à une information ; les effets de survol sont limités à (hover: hover) and (pointer: fine).
  7. Orientation : le site fonctionne dans les deux orientations ; il ne force jamais l'orientation.

4.6.4 Viewports de test #

Nom Taille Raison
Mobile minimal 320 × 640 Largeur minimale supportée
Mobile courant 360 × 740 Android courant
Mobile de référence 390 × 844 Référence mobile
Mobile large 430 × 932 Grands téléphones
Tablette portrait 768 × 1024 Seuil tablette
Tablette de référence 834 × 1194 Référence tablette
Téléphone paysage 844 × 390 Hauteur réduite
Ordinateur compact 1024 × 768 Seuil ordinateur
Ordinateur portable 1280 × 720 Écran courant
Ordinateur de référence 1440 × 900 Référence ordinateur
Grand écran 1920 × 1080 Hero réglé pour 1920
  • AC-4.6-01 : à chacun de ces viewports, / n'a aucun défilement horizontal et passe le test de non-chevauchement (§4.5.5).
  • AC-4.6-02 : à 1024 × 768, la composition du hero à deux colonnes (7fr / 5fr) est active, sans chevauchement entre colonne de texte, portrait et rail.
  • AC-4.6-03 : à 1023 × 768, la pile verticale est active (aucune composition à deux colonnes).

4.7 États vides et conditionnels au niveau de la page #

Condition Effet sur la page Critère
Aucun témoignage publiable (état livré par défaut) #temoignages absent ; lien absent du header, du menu mobile et du footer ; l'alternance des fonds claires se recalcule (§4.1.2) ; aucun espace vide laissé ; sitemap et métadonnées inchangés AC-4.7-01
Un seul témoignage publiable Section affichée avec la citation seule, sans flèches ni indicateurs ni carrousel AC-4.7-02
Témoignage published: true invalide (sans consentement, texte vide ou de plus de 700 caractères) Le build échoue (G-03) ; jamais de rendu partiel AC-4.7-03
Analytique désactivée (défaut) Aucun script, aucune requête tierce, aucun cookie ; track() ne fait rien ; la section « mesure d'audience » de la politique de confidentialité est absente AC-4.7-04
Analytique activée Un seul script tiers (Umami) chargé après l'hydratation ; en-tête CSP étendu à son origine ; section « mesure d'audience » de la politique présente ; aucune bannière de consentement AC-4.7-05
JavaScript désactivé Comportement de §3.5 : contenu lisible, ancres actives, menu en liste, formulaire remplacé par les liens de contact AC-4.7-06
Mouvement réduit Comportement de §4.3.7 AC-4.7-07
Images de repli en développement Les SVG de repli remplacent paysage et portrait ; un repère discret « visuel de développement » est visible en développement uniquement ; jamais en production (G-02) AC-4.7-08
Image qui ne charge pas Le conteneur conserve sa taille réservée (aucun décalage), un fond de couleur de la palette s'affiche, le texte reste lisible ; le portrait manquant ne casse pas la composition AC-4.7-09
Polices qui ne chargent pas Les polices de repli à métriques ajustées s'affichent ; aucun décalage notable AC-4.7-10
Données légales incomplètes en développement ou préproduction Bandeau « Informations légales à fournir par Malka avant mise en production » aux emplacements manquants AC-4.7-11
Aucune offre mise en avant Toutes les offres sont des lignes éditoriales de même poids visuel ; aucun espace vide laissé par l'absence du panneau sombre AC-4.7-12
Offre à « Sur devis » Aucun montant ni symbole « € » affiché ; le libellé « Sur devis » remplace le tarif AC-4.7-13
API de contact injoignable Le formulaire passe à l'état d'erreur de J9 (message, alternatives, texte conservé) ; le reste de la page est intact AC-4.7-14
Fragment d'URL inconnu La page s'affiche en haut sans erreur AC-4.7-15
Visite de / en staging noindex et robots.txt restrictif présents AC-4.7-16
URL inexistante Page 404 en français avec retour à l'accueil et moyens de contact AC-4.7-17
Erreur de rendu inattendue Frontière d'erreur affichant un message en français et un bouton pour réessayer ; aucune trace technique visible AC-4.7-18

Les critères AC-4.7-nn ci-dessus se lisent : « dans la condition de la ligne, le comportement décrit est observé et testé » ; leur mise en œuvre détaillée est dans la section propriétaire de l'élément concerné (§6 pour les sections, §9 et §10 pour l'analytique, §11 pour l'indexation).

5. Design system #

Cette section est la source unique des valeurs visuelles du site : couleurs, contrastes, dégradés, typographie, espacements, rayons, ombres, icônes, règles d'imagerie, catalogue de composants et déclaration Tailwind. Les tokens de mouvement (durées, courbes, ressorts, décalages) appartiennent à la section 8 ; ce chapitre les référence sans les redéfinir. Les textes visibles (copy) appartiennent à la section 7 ; les ancres et identifiants de sections à la section 4.2 ; les versions de dépendances à la section 9.1.

5.1 Principes et architecture des tokens #

5.1.1 Principes directeurs #

  1. Sobriété premium : une seule couleur d'accent chaude (champagne) par zone d'écran, jamais deux appels à l'action champagne dans le même viewport hors hero.
  2. Profondeur par la lumière, pas par la décoration : atmosphères (halos, brume, grain) très faibles ; aucun dégradé sur du texte (le mot géant « CLARTÉ », décoratif, est un aplat translucide).
  3. Lisibilité d'abord : tout texte repose sur un fond dont le contraste est calculé (§5.3). Aucun texte ne repose sur une photographie sans voile (« scrim ») conforme à §5.4.
  4. Une seule source : aucune valeur hexadécimale, aucun rgb() ni durée codés en dur dans les composants ; tout passe par les classes utilitaires ou les variables CSS décrites ici.
  5. Thème par attribut : l'alternance sections sombres / claires est pilotée par data-theme (§5.10), jamais par des classes de couleur propres à chaque section.

5.1.2 Trois couches de tokens #

Couche Exemple Où Usage
Primitives --color-forest-900 @theme dans src/styles/globals.css Palette brute, exposée en utilitaires Tailwind (bg-forest-900).
Sémantiques --surface, --fg, --accent :root et [data-theme] dans src/styles/globals.css Rôles ; changent selon le thème de la section. Utilisés par les composants.
Composants --btn-bg, --card-border Règles locales du composant (classes Tailwind arbitraires [--btn-bg:…]) Surcharges ponctuelles, toujours basées sur une sémantique.

5.1.3 Fichiers #

Fichier Contenu
src/styles/globals.css @import "tailwindcss", bloc @theme complet (§5.11), thèmes sémantiques, couche base, utilitaires @utility.
src/lib/design/tokens.ts Seul fichier TypeScript autorisé à contenir des littéraux hexadécimaux. Constantes des valeurs nécessaires hors CSS : THEME_COLOR (#13301F, importée par viewport.themeColor du layout racine), ERROR_PAGE_COLORS = { bg, fg, accent } (couleurs de global-error.tsx, qui ne peut pas charger la feuille de style : forest-950, ivory-50, champagne-400), HEADER_HEIGHT = { desktop: 72, mobile: 64 }, BREAKPOINTS. Aucune autre valeur n'y est dupliquée.
src/lib/typography/fr.ts Helpers de typographie française (§5.5.6).
src/lib/cn.ts cn() : twMerge(clsx(...)) (dépendances en §9.1).
public/textures/grain.svg Texture de grain (§5.4.7).

Un test unitaire tests/unit/design/no-hardcoded-colors.test.ts échoue si un fichier de src/**/*.{ts,tsx,css} autre que globals.css et tokens.ts contient un littéral #RRGGBB ou #RGB, ou si un fichier autre que ces deux-là et src/styles/motion.css (qui peut écrire rgb( pour l'ombre pré-rendue des survols, sans aucun littéral hexadécimal) contient rgb( ou hsl(.

5.2 Couleurs #

5.2.1 Palette primitive (verrouillée) #

Token Hex Rôle principal
forest-950 #0E2219 Fond le plus profond (hero, footer, header, voiles). Texte sur boutons champagne.
forest-900 #13301F Fond sombre standard (méthode), bouton primaire sur clair, theme-color.
forest-800 #1B4030 Surfaces sombres surélevées, survol de bouton primaire sur clair, texte fort sur clair.
forest-700 #275640 Bordures et remplissages sur sombre ; texte sur clair (titres secondaires).
sage-600 #5E7A64 Bordures de champs, icônes, filets (non-texte uniquement).
sage-400 #8FA88F Texte secondaire sur forest-950/forest-900 uniquement, bordures décoratives sur sombre.
sage-200 #D3DDD0 Texte atténué sur sombre ; fond de pastille, bouton désactivé, bordure de bouton fantôme sur sombre.
ivory-50 #FBF8F1 Fond clair A, texte principal sur sombre.
ivory-100 #F4EEE1 Fond clair B (alternance), surfaces de cartes sur clair A.
ivory-200 #E9E0CC Texte atténué fort sur sombre, séparateurs, fond de zone neutre.
champagne-600 #9A7638 Filets, icônes, grands chiffres/mots décoratifs sur clair (voir restrictions §5.3.4).
champagne-400 #D4B47A Accent principal sur sombre : bouton primaire, lien actif, focus, filets.
champagne-200 #EBD9B3 Survol du bouton primaire, mot « CLARTÉ », halos lumineux.
turquoise-600 #23736F Liens et eyebrows sur clair, anneau de focus sur clair.
turquoise-400 #4FA8A2 Lueur de rivière, icône décorative sur forest-950/forest-900.
turquoise-200 #A9D9D3 Brume turquoise, texte secondaire ponctuel sur sombre.
ink-900 #17201B Texte courant sur clair.
ink-600 #44514A Texte atténué sur clair, placeholders, légendes.
error #A33A2B Erreurs (clair uniquement).
success #2E6B47 Confirmations (clair uniquement).

Le blanc pur (#FFFFFF) et le noir pur (#000000) sont interdits dans l'interface (hors rgb(0 0 0 / x) pour les ombres natives des éléments système).

5.2.2 Tokens sémantiques #

Valeurs par thème (le thème est porté par l'attribut data-theme de l'élément ou d'un ancêtre ; voir §5.10) :

Token sémantique Utilitaire Tailwind Thème dark Thème light
--surface bg-surface forest-900 ivory-50
--surface-alt bg-surface-alt forest-950 ivory-100
--surface-raised bg-surface-raised forest-800 ivory-50
--fg text-fg ivory-50 ink-900
--fg-strong text-fg-strong ivory-50 forest-900
--fg-muted text-fg-muted sage-200 ink-600
--accent text-accent, bg-accent, border-accent champagne-400 champagne-600 (non-texte ou grand texte uniquement)
--eyebrow text-eyebrow champagne-400 turquoise-600
--link text-link champagne-400 turquoise-600
--link-hover text-link-hover champagne-200 forest-900
--border border-line rgb(251 248 241 / 0.16) rgb(23 32 27 / 0.14)
--border-strong border-line-strong sage-200 forest-800
--field-border border-field sage-200 sage-600
--focus outline-focus champagne-400 turquoise-600
--halo — (CSS direct) champagne-200 champagne-200

Les tokens error et success sont globaux (text-error, text-success, border-error, border-success) et réservés aux fonds clairs (§5.3.4). Sur un fond sombre, un état d'erreur utilise l'icône + un titre ivory-50 + une bordure champagne-400 (jamais le rouge).

5.2.3 Répartition d'usage (guide d'équilibre) #

Zone Dominante Accent Note
Hero, Méthode, Footer forest-950 / forest-900 (≈ 70 %) champagne-400 + champagne-200 (≈ 8 %) ivory-50 pour le texte (≈ 20 %).
Philosophie, Accompagnements, Orientation, À propos, Témoignages, Contact ivory-50 / ivory-100 (≈ 75 %) turquoise-600 pour liens/eyebrows (≈ 3 %), champagne-600 pour filets (≈ 2 %) ink-900 texte (≈ 20 %).
Carte offre mise en avant (featured) Thème dark imbriqué (forest-900) dans une section claire champagne-400 Seule inversion de thème autorisée à l'intérieur d'une section.

5.3 Contrastes WCAG 2.x #

5.3.1 Méthode et seuils #

Rapport de contraste = (L₁ + 0,05) / (L₂ + 0,05), avec L la luminance relative WCAG 2 (sRGB linéarisé). Tous les chiffres ci-dessous sont calculés sur les valeurs hexadécimales de §5.2.1, arrondis à 0,01 par défaut.

Catégorie Seuil Application
Texte normal (< 24 px, ou < 18,66 px en gras) ≥ 4,5 : 1 Tout le texte courant, boutons, labels, liens.
Grand texte (≥ 24 px, ou ≥ 18,66 px et graisse ≥ 700) ≥ 3 : 1 Titres display, grands chiffres, prix.
Composants d'interface et graphismes porteurs d'information ≥ 3 : 1 Bordures de champs, icônes seules, anneaux de focus, état coché.
Décoratif pur (aria-hidden) aucune exigence Mot « CLARTÉ », halos, brume, grain, paysages.

Objectif projet : tout texte courant ≥ 4,5 : 1 et la plupart ≥ 7 : 1 ; le tableau de §5.3.2 et §5.3.3 liste les seules combinaisons autorisées.

5.3.2 Texte sur fonds sombres #

Texte \ Fond forest-950 forest-900 forest-800 forest-700 Statut
ivory-50 15,69 13,47 10,86 7,95 AA/AAA partout — texte principal
ivory-100 14,40 12,35 9,97 7,29 AA/AAA partout
ivory-200 12,68 10,88 8,78 6,42 AA/AAA partout
sage-200 11,91 10,22 8,24 6,03 AA/AAA partout — texte atténué
champagne-200 11,98 10,28 8,29 6,06 AA/AAA partout
champagne-400 8,41 7,22 5,82 4,26 AA sur 950/900/800 ; interdit en texte normal sur forest-700 (grand texte seulement)
turquoise-200 10,74 9,22 7,44 5,44 AA/AAA partout
sage-400 6,47 5,55 4,48 3,28 AA sur 950/900 uniquement ; interdit sur 800/700 en texte normal
turquoise-400 5,91 5,07 4,09 2,99 AA sur 950/900 uniquement ; interdit en texte sur 800/700

5.3.3 Texte sur fonds clairs #

Texte \ Fond ivory-50 ivory-100 ivory-200 sage-200 Statut
ink-900 15,73 14,43 12,71 11,93 AA/AAA partout — texte courant
ink-600 7,85 7,20 6,34 5,96 AA/AAA partout — texte atténué, placeholders
forest-900 13,47 12,35 10,88 10,22 AA/AAA partout — titres
forest-800 10,86 9,97 8,78 8,24 AA/AAA partout — prix, titres de carte
forest-700 7,95 7,29 6,42 6,03 AA/AAA partout
turquoise-600 5,28 4,84 4,26 4,00 AA sur ivory-50 et ivory-100 uniquement (liens, eyebrows) ; interdit sur ivory-200 et sage-200 en texte normal
error 6,19 5,68 5,00 4,70 AA partout — messages d'erreur
success 5,98 5,49 4,83 4,54 AA partout — messages de succès
champagne-600 3,94 3,62 3,19 2,99 Grand texte et non-texte seulement sur ivory-50/ivory-100/ivory-200 ; interdit sur sage-200
sage-600 4,46 4,09 3,60 3,38 Non-texte uniquement (bordures, icônes) ; interdit en texte
sage-400 2,42 2,22 1,96 1,84 Interdit (texte et non-texte porteur d'information)
champagne-400 1,87 1,71 1,51 1,42 Interdit sur clair (texte et non-texte porteur d'information)

5.3.4 Combinaisons INTERDITES pour du texte (liste de blocage) #

Aucun de ces couples n'est utilisé pour du texte de corps, d'étiquette, de bouton, de lien ou de placeholder. Le linter de tokens de §5.1.3 et la revue visuelle refusent toute classe correspondante.

Interdit Rapport Raison
champagne-600 en texte < 24 px (ou < 18,66 px gras) sur tout fond clair 3,94 max Sous 4,5 : 1. Autorisé uniquement pour grand texte, filets, icônes. Les eyebrows et liens sur clair utilisent turquoise-600.
champagne-600 sur fond sombre (texte ou icône) 3,42 sur forest-900 Trop faible ; sur sombre on utilise champagne-400.
champagne-400 sur tout fond clair ≤ 1,87 Illisible.
sage-400 sur fond clair ≤ 2,42 Illisible.
sage-600 en texte (clair ou sombre) 4,46 max sur clair ; 3,02 sur forest-900 Sous 4,5 : 1. Réservé aux bordures/icônes.
turquoise-400 en texte sur forest-800/forest-700 4,09 / 2,99 Sous 4,5 : 1.
turquoise-400 sur fond clair < 3 Réservé à la lueur décorative sur sombre.
turquoise-600 en texte sur ivory-200 ou sage-200 4,26 / 4,00 Sous 4,5 : 1.
ink-900, ink-600, forest-* sur fond sombre ≤ 2,55 Inversion de thème oubliée.
error ou success sur fond sombre 2,17 / 2,25 sur forest-900 Illisible ; voir §5.2.2.
Texte ou icône informatifs en opacity < 1 (texte semi-transparent) variable Le contraste doit être garanti par une couleur pleine du tableau, pas par opacity.
Tout texte posé directement sur une photographie sans voile conforme à §5.3.5 — Contraste non maîtrisé.

5.3.5 Texte sur image : voiles minimum #

Voile (« scrim ») = aplat forest-950 (#0E2219) d'opacité α posé sur l'image. Valeurs calculées sur le pire cas théorique (image au blanc ivoire ivory-50 sous le voile) :

α du voile ivory-50 ivory-200 sage-200 champagne-400 Conséquence
0,60 4,35 3,52 3,30 2,33 Insuffisant
0,70 6,02 4,86 4,57 3,23 Texte ivory-50/ivory-200/sage-200 seulement ; pas de champagne
0,75 7,13 5,76 5,41 3,82 Idem ; pas de texte champagne
0,80 8,46 6,83 6,42 4,53 Minimum retenu : tout texte, y compris liens champagne-400
0,90 11,81 9,55 8,96 6,33 Zone très confortable

Règle : dans l'emprise de tout texte posé sur une image (hero, footer), l'opacité effective du voile est ≥ 0,80 à chaque pixel de la boîte du texte (marge de 8 px incluse). Les étiquettes du rail de méthode du hero (côté lumineux de l'image, étiquettes horizontales) sont posées sur des capsules forest-950 à 0,80 minimum avec texte ivory-50 (8,46 : 1 au pire). Les bordures d'un bouton fantôme sur image utilisent sage-200 (≥ 6,42 : 1 au pire) et non sage-400 (2,94 : 1 sur un voile à 0,75).

5.3.6 Contrastes des composants (non-texte, ≥ 3 : 1) #

Élément Couleur Fond adjacent Rapport
Anneau de focus sur sombre champagne-400 forest-900 7,22
Anneau de focus sur sombre (au-dessus d'un voile à 0,80) champagne-400 pire cas 4,53
Anneau de focus sur clair turquoise-600 ivory-50 / ivory-100 / ivory-200 / sage-200 5,28 / 4,84 / 4,26 / 4,00
Bordure de champ (repos) sage-600 ivory-50 / ivory-100 4,46 / 4,09
Bordure de champ (survol) ink-600 ivory-50 7,85
Bordure de champ en erreur error ivory-50 6,19
Bordure bouton fantôme sur clair forest-800 ivory-50 10,86
Bordure bouton fantôme sur sombre sage-200 forest-900 10,22
Filet / icône décorative informative sur clair champagne-600 ivory-50 / ivory-100 3,94 / 3,62
Bordure de carte featured champagne-400 forest-900 7,22
Case à cocher cochée (coche) ivory-50 sur forest-900 — 13,47

Les bordures de cartes non interactives (--border, ≈ 1,3 : 1) sont purement décoratives ; l'information ne repose jamais sur elles (la séparation visuelle s'appuie aussi sur l'espacement et le changement de fond).

5.3.7 Contrastes des boutons (texte) #

Bouton / état Texte Fond Rapport
Primaire sur sombre — repos forest-950 champagne-400 8,41
Primaire sur sombre — survol forest-950 champagne-200 11,98
Primaire sur clair — repos ivory-50 forest-900 13,47
Primaire sur clair — survol ivory-50 forest-800 10,86
Fantôme sur sombre — repos / survol (voile ivory-50 10 %) ivory-50 forest-900 / mélange 13,47 / 10,02
Fantôme sur clair — repos forest-900 ivory-50 13,47
Désactivé (aria-disabled) ink-600 sage-200 5,96
Lien-bouton sur clair turquoise-600 ivory-50 / ivory-100 5,28 / 4,84
Lien-bouton sur sombre champagne-400 forest-900 7,22

Si un halo champagne (champagne-200) est superposé à un fond clair derrière du texte, son opacité ne dépasse pas 0,30 : turquoise-600 reste alors ≥ 4,58 sur ivory-100 (4,87 sur ivory-50) et ink-600 ≥ 6,82.

5.4 Dégradés et surcouches atmosphériques #

Toutes les surcouches : position: absolute, pointer-events: none, aria-hidden="true", z-index selon §5.7.4, jamais d'ombre ou de flou animés. Les animations de dérive (halos, brume) sont définies en section 8 ; ici, seul l'état de repos.

Les gradients utilisent l'interpolation in oklab (meilleure continuité des verts) avec repli automatique : chaque déclaration in oklab est précédée de la même déclaration sans in oklab (les navigateurs qui ne comprennent pas la seconde conservent la première).

5.4.1 Variables de dégradés (dans :root) #

:root {
  /* Hero — voile horizontal (desktop ≥ 1024px), seul voile du hero. α ≥ 0.80 jusqu'à 60 % de la largeur :
     le bord droit de la colonne de texte (titre, description, boutons, chiffres de confiance, rail horizontal
     de 1024–1279 px) se situe à ≈ 56 % de la largeur du viewport entre 1024 et 1440 px. */
  --scrim-hero-x: linear-gradient(90deg,
    rgb(14 34 25 / 0.94) 0%,
    rgb(14 34 25 / 0.86) 25%,
    rgb(14 34 25 / 0.80) 60%,
    rgb(14 34 25 / 0.36) 72%,
    rgb(14 34 25 / 0) 88%);

  /* Header transparent en haut du hero : α ≥ 0.80 sur les 72 px de la barre (52 % de 140 px). */
  --scrim-header: linear-gradient(180deg,
    rgb(14 34 25 / 0.88) 0%,
    rgb(14 34 25 / 0.80) 52%,
    rgb(14 34 25 / 0) 100%);

  /* Vignettage du hero (bords). */
  --vignette-hero: radial-gradient(120% 90% at 70% 40%,
    rgb(14 34 25 / 0) 55%,
    rgb(14 34 25 / 0.55) 100%);

  /* Halo champagne derrière le portrait. */
  --halo-champagne: radial-gradient(closest-side,
    rgb(235 217 179 / 0.55) 0%,
    rgb(212 180 122 / 0.22) 38%,
    rgb(212 180 122 / 0) 72%);

  /* Brume horizontale (bande de vallée). */
  --mist-sage: linear-gradient(180deg,
    rgb(211 221 208 / 0) 0%,
    rgb(211 221 208 / 0.28) 45%,
    rgb(211 221 208 / 0) 100%);

  /* Lueur de rivière (footer). */
  --glow-river: radial-gradient(ellipse 60% 40% at 50% 100%,
    rgb(79 168 162 / 0.35) 0%,
    rgb(79 168 162 / 0) 70%);

  /* Voile du footer — texte uniquement dans la zone α ≥ 0.80 (haut) ou sur la barre légale pleine. */
  --scrim-footer: linear-gradient(180deg,
    rgb(14 34 25 / 0.96) 0%,
    rgb(14 34 25 / 0.84) 55%,
    rgb(14 34 25 / 0.45) 100%);

  /* Transitions entre sections (bandes dont la hauteur est fixée par la section qui les porte). */
  --edge-hero-to-light: linear-gradient(180deg,
    rgb(14 34 25 / 0) 0%,
    rgb(143 168 143 / 0.35) 40%,
    rgb(233 224 204 / 0.85) 78%,
    rgb(251 248 241) 100%);
  --edge-light-to-dark: linear-gradient(180deg,
    rgb(251 248 241) 0%,
    rgb(251 248 241 / 0) 100%);
  --edge-dark-to-light: linear-gradient(180deg,
    rgb(19 48 31 / 0) 0%,
    rgb(19 48 31 / 0) 40%,
    rgb(251 248 241) 100%);

  /* Lumière chaude sur sections claires (discret : α champagne ≤ 0.30). */
  --warmth-light: radial-gradient(60% 50% at 85% 0%,
    rgb(235 217 179 / 0.30) 0%,
    rgb(235 217 179 / 0) 70%);
}

Les gradients sont définis sans mot-clé de couleur nommée ; pour les rendre in oklab, la couche base ajoute (une seule fois, @supports (background: linear-gradient(in oklab, red, blue))) les variantes :

@supports (background: linear-gradient(in oklab, red, blue)) {
  :root {
    --scrim-hero-x: linear-gradient(in oklab 90deg,
      rgb(14 34 25 / 0.94) 0%, rgb(14 34 25 / 0.86) 25%, rgb(14 34 25 / 0.80) 60%,
      rgb(14 34 25 / 0.36) 72%, rgb(14 34 25 / 0) 88%);
    --edge-hero-to-light: linear-gradient(in oklab 180deg,
      rgb(14 34 25 / 0) 0%, rgb(143 168 143 / 0.35) 40%,
      rgb(233 224 204 / 0.85) 78%, rgb(251 248 241) 100%);
    --edge-light-to-dark: linear-gradient(in oklab 180deg, rgb(251 248 241) 0%, rgb(251 248 241 / 0) 100%);
    --edge-dark-to-light: linear-gradient(in oklab 180deg,
      rgb(19 48 31 / 0) 0%, rgb(19 48 31 / 0) 40%, rgb(251 248 241) 100%);
  }
}

5.4.2 Hero — empilement et voiles #

Valable à partir de 1024 px. Le hero est un contexte d'empilement (position: relative; isolation: isolate) ; toutes les valeurs de z-index ci-dessous sont locales à ce contexte et nommées en §5.7.4.

Calque (bas → haut) Contenu Variable / valeur z (§5.7.4)
0 Fond plein background-color: var(--color-forest-950) sur la section —
1 Paysage lointain Image hero-far (ciel + crêtes) --z-hero-far
2 Brume de vallée Image hero-mid + --mist-sage en bande basse --z-hero-mid
3 Voile de lisibilité --scrim-hero-x + --vignette-hero --z-hero-scrim
4 Mot CLARTÉ §5.4.6 --z-hero-word
5 Halo --halo-champagne, diamètre 70vmin, centré sur le portrait --z-hero-halo
6 Portrait Image hero-portrait --z-hero-portrait
7 Crête proche Image hero-near (devant le bas du portrait) --z-hero-near
8 Voile d'entrée Aplat forest-950 dont l'opacité passe de 1 à 0 au chargement (section 8) --z-hero-veil
9 Grain §5.4.7 --z-hero-grain
10 Contenu (texte, boutons, chiffres de confiance, rail) — --z-hero-content
11 Transition vers la section suivante --edge-hero-to-light, hauteur clamp(96px, 14svh, 160px), ancrée en bas --z-hero-edge

Le voile est placé sous le mot, le halo et le portrait : le visage n'est jamais assombri. Le texte, les chiffres de confiance et le rail se situent dans la colonne de texte, entièrement couverte par la zone α ≥ 0,80 de --scrim-hero-x (de 0 à 60 % de la largeur) sur toute la hauteur : aucun voile vertical n'est nécessaire.

Sous 1024 px, le hero n'empile pas de calques derrière le texte : fond uni forest-950 (dégradé forêt discret sans image), texte au-dessus, puis une bande encadrée (rayon 20 px, overflow: clip) qui contient hero-mobile en object-fit: cover, le mot CLARTÉ et le portrait. Aucun texte n'est posé sur une image et aucun voile de lisibilité n'est requis ; la composition complète est en §4.4.2 et §6.2.2.

@media (min-width: 1024px) {
  .hero-scrim {
    position: absolute;
    inset: 0;
    z-index: var(--z-hero-scrim);
    pointer-events: none;
    background: var(--vignette-hero), var(--scrim-hero-x);
  }
}
.hero-halo {
  position: absolute;
  z-index: var(--z-hero-halo);
  width: 70vmin;
  aspect-ratio: 1;
  background: var(--halo-champagne);
  pointer-events: none;
  /* Centrage et position finale : définis par la section 6.2 ; no filter: blur() (le dégradé est déjà doux). */
}
.hero-edge {
  position: absolute;
  z-index: var(--z-hero-edge);
  inset-inline: 0;
  bottom: 0;
  height: clamp(96px, 14svh, 160px);
  background: var(--edge-hero-to-light);
  pointer-events: none;
}

Le voile du hero doit garantir §5.3.5 dans la boîte de chaque texte ; la vérification automatisée est décrite en §5.13 (AC-5.4-03).

5.4.3 Header #

Le header est toujours visible (position: sticky, aucun masquage au défilement). Trois états, portés par l'attribut data-state :

État Où Fond
top / uniquement, tant que scrollY < 24 Transparent, avec voile --scrim-header
scrolled / après 24 px de défilement ; retour à top sous 8 px (hystérésis) forest-950 à 0,92 sous 768 px ; 0,85 + flou à partir de 768 px
solid Pages légales, 404, erreur : dès le chargement forest-950 plein
.site-header[data-state="top"]    { background: transparent; }
.site-header[data-state="top"]::before {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 140px;
  background: var(--scrim-header); pointer-events: none; z-index: -1;
}
.site-header[data-state="scrolled"] {
  background-color: rgb(14 34 25 / 0.92);   /* forest-950, α 0.92 : ivory-50 12,58 · champagne-400 6,74 au pire */
  border-bottom: 1px solid var(--border);
}
@media (min-width: 768px) {
  .site-header[data-state="scrolled"] {
    background-color: rgb(14 34 25 / 0.85);
    backdrop-filter: blur(12px) saturate(1.1);
  }
  @supports not (backdrop-filter: blur(1px)) {
    .site-header[data-state="scrolled"] { background-color: rgb(14 34 25 / 0.97); }
  }
}
.site-header[data-state="solid"] { background-color: var(--color-forest-950); } /* pages légales, 404, erreur */

backdrop-filter n'est utilisé que sur le header scrolled et uniquement à partir de 768 px (coût GPU), avec la valeur unique blur(12px) ; en dessous de 768 px, ou si le navigateur ne le prend pas en charge, le fond est quasi opaque (0,92 sous 768 px, 0,97 en repli). La hauteur du header est constante dans les trois états (72 px ≥ 1024 px ; 64 px en dessous) : aucun décalage de mise en page au passage top → scrolled. Le comportement détaillé (seuils, navigation, menu mobile) est en §6.1.

5.4.4 Brume (mist) #

--mist-sage est posée en bande (height: 38%, bottom: 0) au-dessus de hero-mid. En section claire, une variante discrète est autorisée : background: radial-gradient(60% 50% at 20% 100%, rgb(169 217 211 / 0.30), rgb(169 217 211 / 0)) ; avec α ≤ 0,30, turquoise-600 reste ≥ 4,72 sur le mélange ivory-50 (calculé).

Le décor du footer (montagnes et rivière) est entièrement vectoriel (SVG en ligne, section 6.10) : il n'utilise aucun fichier d'image. --glow-river est posée au pied du footer, hauteur min(55%, 420px), sous la barre légale. La barre légale (liens « Mentions légales », « Politique de confidentialité », mention de droits) repose sur un aplat plein forest-950 : aucun texte n'est jamais superposé à la lueur ou aux silhouettes sans voile --scrim-footer à α ≥ 0,80 (zone haute du voile : 0 à 55 % de la hauteur du décor).

5.4.6 Mot géant « CLARTÉ » #

Élément décoratif : aria-hidden="true", user-select: none, pointer-events: none, non sélectionnable, absent du plan de lecture. Ne porte aucun texte porteur d'information : il ne doit jamais être le seul porteur d'un mot-clé. Sa couleur est pleine et plate (aucun dégradé) ; l'effet de profondeur vient uniquement de son opacité fixe et de l'ordre des calques (le portrait recouvre les dernières lettres, §5.4.2).

.word-clarte {
  font-family: var(--font-display), ui-serif, Georgia, serif;
  font-weight: 600;
  font-size: var(--clarte-size, clamp(14rem, 21vw, 22rem));   /* hero ≥ 1024 px, centré sur le hero */
  line-height: 0.8;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
  color: rgb(235 217 179 / var(--clarte-opacity, 0.14));      /* champagne-200, aplat */
}
.word-clarte--band {                                           /* hero < 1024 px : à l'intérieur de la bande encadrée */
  --clarte-size: clamp(4rem, 20vw, 7.5rem);
}
.word-clarte--footer {
  --clarte-size: clamp(6rem, 22vw, 20rem);
  --clarte-opacity: 0.08;
  color: rgb(251 248 241 / var(--clarte-opacity));            /* ivory-50, aplat */
}
@media (forced-colors: active) { .word-clarte { display: none; } }
  • Opacités fixes : 0,14 dans le hero et la bande, 0,08 dans le footer ; elles sont aussi les valeurs de WORD_OPACITY en section 8 ({ hero: 0.14, footer: 0.08 }). Un texte ivory-50 superposé au mot du hero (champagne-200 à 0,14 sur forest-900) reste à 9,4 : 1 (calculé) : un chevauchement accidentel texte/mot est donc sans conséquence de lisibilité, mais la composition (section 6.2) évite tout chevauchement.
  • Le mot est entièrement contenu : overflow-x: clip sur la section (jamais overflow: hidden, qui casserait position: sticky). Une vérification visuelle (AC-5.4-05) refuse une coupe > 3 % de la largeur du mot à 320, 768, 1440 et 1920 px ; si elle échoue, ajuster uniquement --clarte-size.
  • Le mot écrit toujours « CLARTÉ » (É majuscule accentué) ; il n'est jamais traduit.

5.4.7 Texture de grain #

Tuile SVG public/textures/grain.svg (≈ 300 octets ; limite dure 4 Ko), répétée en 200 × 200 px, bruit fractal en niveaux de gris :

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
  <filter id="n" x="0" y="0" width="100%" height="100%">
    <feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" stitchTiles="stitch"/>
    <feColorMatrix type="saturate" values="0"/>
  </filter>
  <rect width="200" height="200" filter="url(#n)"/>
</svg>
@utility grain {
  position: relative;
  isolation: isolate;
  &::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: var(--grain-z, var(--z-base));   /* le hero pose --grain-z: var(--z-hero-grain) */
    background-image: url("/textures/grain.svg");
    background-size: 200px 200px;
    opacity: var(--grain-opacity, 0.04);
    pointer-events: none;
  }
}
[data-theme="dark"]  { --grain-opacity: 0.05; }
[data-theme="light"] { --grain-opacity: 0.03; }

Règles : opacité comprise entre 0,03 et 0,05 ; jamais de mix-blend-mode ni de grain position: fixed plein écran (repeints coûteux) ; appliqué aux sections hero, méthode, footer et, optionnellement, aux sections claires via la classe grain ; désactivé en forced-colors: active (display: none sur le pseudo-élément). Le SVG est servi depuis public/textures/ avec Cache-Control: public, max-age=31536000, immutable (§11.3.8) ; son nom change si son contenu change.

5.5 Typographie #

5.5.1 Familles #

Rôle Famille Chargement Axes
Titres, chiffres, citations, logo Fraunces romain (variable) next/font/google, auto-hébergée au build, préchargée opsz (automatique, font-optical-sizing: auto) + wght ; usage limité à 300–600
Emphase dans les titres et citations d'accroche (em) Fraunces italique (variable) Seconde instance next/font/google, auto-hébergée, non préchargée (preload: false) opsz + wght ; usage limité à 300–500
Interface, corps de texte, boutons, formulaires Manrope (variable) next/font/google, auto-hébergée au build, préchargée wght ; usage limité à 400–700

Décision : l'italique de Fraunces est chargée comme seconde instance next/font non préchargée (preload: false). Le navigateur ne la télécharge que lorsqu'un em de titre ou une citation d'accroche est effectivement rendu ; aucun préchargement n'alourdit le chemin critique. font-synthesis: none est posé sur html (§5.11) : le navigateur ne fabrique jamais de faux italique ni de faux gras. L'italique est réservée aux em des titres (h1–h3) et aux citations d'accroche (.pull-quote) : dans le corps de texte, em est rendu en graisse 600 (Manrope n'a pas d'italique) sans changement de couleur (le contraste du texte courant n'est jamais modifié, §5.3.4). L'accent coloré de l'em n'est appliqué qu'aux textes de 24 px et plus (h1, h2, citation d'accroche), jamais à un h3 ni au corps de texte. Budget de l'italique : un seul fichier woff2 de plus (voir §11.3.4).

// src/app/fonts.ts
import { Fraunces, Manrope } from 'next/font/google';

export const fraunces = Fraunces({
  subsets: ['latin', 'latin-ext'],
  axes: ['opsz'],            // wght variable par défaut
  style: ['normal'],
  display: 'swap',
  variable: '--font-display',
  adjustFontFallback: true,
});

export const frauncesItalic = Fraunces({
  subsets: ['latin', 'latin-ext'],
  axes: ['opsz'],
  style: ['italic'],
  display: 'swap',
  variable: '--font-display-italic',
  preload: false,            // chargée à la demande, jamais préchargée
  adjustFontFallback: true,
});

export const manrope = Manrope({
  subsets: ['latin', 'latin-ext'],
  display: 'swap',
  variable: '--font-sans',
  adjustFontFallback: true,
});

src/app/layout.tsx applique className={${fraunces.variable} ${frauncesItalic.variable} ${manrope.variable}} sur <html lang="fr">. Attention à la collision de noms : les variables --font-display, --font-display-italic et --font-sans sont posées par next/font, donc les déclarations Tailwind correspondantes utilisent @theme inline pour référencer ces variables sans les redéfinir (§5.11).

5.5.2 Échelle fluide #

Interpolation linéaire entre 320 px et 1440 px de viewport, bornée ; toutes les valeurs en rem + vw pour respecter le zoom du texte (WCAG 1.4.4). Taille minimale de texte affichée : 12,8 px (0,8rem).

Token clamp() px à 320 / 768 / 1024 / 1440 Interligne Interlettrage Graisse Usage
display-xl ≥ 1024 px : clamp(2.25rem, 4.4vw, 3.75rem) ; < 1024 px : clamp(2.25rem, 7vw, 3.25rem) 36 / 52 / 45 / 60 1,05 -0,025em Fraunces 400 H1 du hero uniquement
display-lg clamp(2.125rem, 1.5893rem + 2.6786vw, 4rem) 34 / 46 / 53 / 64 1,05 -0,02em Fraunces 400 Phrase manifeste de la Philosophie
display-md clamp(1.875rem, 1.5536rem + 1.6071vw, 3rem) 30 / 37 / 41 / 48 1,10 -0,015em Fraunces 400 H2 de section
heading-lg clamp(1.5rem, 1.3571rem + 0.7143vw, 2rem) 24 / 27 / 29 / 32 1,20 -0,01em Fraunces 500 H3 majeur (offre, étape de méthode)
heading-md clamp(1.25rem, 1.1786rem + 0.3571vw, 1.5rem) 20 / 22 / 23 / 24 1,25 -0,005em Fraunces 500 Titres de cartes
heading-sm clamp(1.125rem, 1.0893rem + 0.1786vw, 1.25rem) 18 / 19 / 19 / 20 1,30 0 Manrope 600 Sous-titres UI
body-lg clamp(1.1rem, 1.0571rem + 0.2143vw, 1.25rem) 17,6 / 19 / 19 / 20 1,60 0 Manrope 400 Chapô, description du hero
body clamp(1rem, 0.9714rem + 0.1429vw, 1.1rem) 16 / 17 / 17 / 17,6 1,65 0 Manrope 400 Texte courant
body-sm clamp(0.9rem, 0.8857rem + 0.0714vw, 0.95rem) 14,4 / 15 / 15 / 15,2 1,55 0 Manrope 400 Notes, textes de carte secondaires
eyebrow clamp(0.8rem, 0.7857rem + 0.0714vw, 0.85rem) 12,8 / 13 / 13 / 13,6 1,30 0,16em Manrope 600, MAJUSCULES Surtitres de section
caption clamp(0.8rem, 0.7857rem + 0.0714vw, 0.85rem) 12,8 / 13 / 13 / 13,6 1,45 0,01em Manrope 500 Légendes, aides de champ, mentions
button clamp(0.95rem, 0.9357rem + 0.0714vw, 1rem) 15,2 / 15,5 / 15,8 / 16 1 0,01em Manrope 600 Boutons, liens de navigation
figure clamp(2.75rem, 2.1786rem + 2.8571vw, 4.75rem) 44 / 57 / 64 / 76 1,0 -0,02em Fraunces 400, chiffres tabulaires si disponibles TrustFigure
price clamp(2rem, 1.7857rem + 1.0714vw, 2.75rem) 32 / 37 / 41 / 44 1,05 -0,01em Fraunces 500, chiffres tabulaires si disponibles Prix des offres

Notes :

  • display-xl est le seul token à deux valeurs : la valeur ≥ 1024 px est posée par une règle @media (min-width: 1024px) sur :root (§5.11). Au-dessus de 1024 px le H1 occupe exactement trois lignes fixes sans retour automatique (white-space: nowrap par ligne, section 6.2) : une ligne de 22 caractères mesure ≈ 495 px à 1024 px, ≈ 591 px à 1280 px et ≈ 634 px à 1440 px, toujours inférieure à la colonne de texte (509 / 658 / 733 px, §6.2.2).
  • Les valeurs ≥ 24 px sont du « grand texte » (§5.3.1). heading-md à 20 px et en dessous est du texte normal.
  • Les champs de formulaire utilisent font-size: max(1rem, 16px) (jamais plus petit : sinon iOS zoome au focus).
  • font-variant-numeric: lining-nums tabular-nums sur figure, price et le compteur de caractères ; si la police ne propose pas la fonctionnalité, le rendu reste correct.
  • font-optical-sizing: auto sur Fraunces ; aucun font-variation-settings manuel.

5.5.3 Longueurs de ligne et rythme #

Contexte Mesure maximale Propriétés
Corps de texte (body, body-lg) 62–68 caractères : max-width: 66ch text-wrap: pretty
Chapô / description du hero max-width: 52ch text-wrap: pretty
Titres display-* max-width: 18ch (hero) à 22ch (H2) text-wrap: balance
Titres heading-* max-width: 28ch text-wrap: balance
Citations de témoignage max-width: 48ch text-wrap: pretty
Pages légales max-width: 72ch hyphens: auto

Coupures : hyphens: auto (avec lang="fr") uniquement sur les paragraphes de corps < 768 px ; hyphens: manual pour tout titre. Les adresses e-mail et URL longues reçoivent overflow-wrap: anywhere. L'espacement vertical entre paragraphes de corps est 1em ; entre titre et paragraphe, 0.75em à 1em selon le niveau.

5.5.4 Règles d'usage #

Règle Détail
Hiérarchie Un seul display-xl (H1). Les H2 sont display-md (ou display-lg pour la Philosophie).
Casse Les titres sont en casse de phrase. Les MAJUSCULES sont réservées à eyebrow et au mot « CLARTÉ ».
Mise en forme Pas de texte souligné hors liens ; pas de texte justifié ; pas de texte centré au-delà de 3 lignes.
Coupure Pas de lettrine ; aucun texte vertical ni pivoté : les étiquettes du rail de méthode du hero sont horizontales à toutes les largeurs.
Couleur Texte de corps uniquement --fg ou --fg-muted (§5.2.2).

5.5.5 Chargement des polices #

Voir §11.3.4 (préchargement, subsets, budget d'octets, métriques de repli). Aucun <link> vers un domaine tiers de polices n'est autorisé.

5.5.6 Typographie française : règles et assistant #

Règles appliquées à tout texte visible (contenu, boutons, métadonnées affichées, messages de formulaire). Les textes de la section 7 sont écrits en texte normal (espaces simples) : l'assistant frTypo ci-dessous est la seule source des espaces insécables, appliquée une fois au chargement du contenu ; il est idempotent.

Règle Caractère Exemple
Espace fine insécable (U+202F) avant ; ! ? U+202F Prêt ? Bienvenue !
Espace insécable (U+00A0) avant : U+00A0 Contact : 06 …
Espace fine insécable (U+202F) à l'intérieur des guillemets français U+202F après « et avant » « Faire le point »
Espace insécable avant % et entre un nombre et son unité ou son mot court U+00A0 6 mois, 300+ personnes
Prix Produit par Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }) et utilisé tel quel (le séparateur de milliers et l'espace avant « € » sont déjà insécables) 1 000 €
Numéro de téléphone Affiché avec des espaces simples entre les paires de chiffres (« 06 67 29 51 79 ») ; rendu insécable par white-space: nowrap sur le lien, jamais par des caractères spéciaux 06 67 29 51 79
Apostrophe typographique ’ (U+2019) à la place de ' l’entreprise
Points de suspension … (U+2026) à la place de ... Envoi en cours…
Tiret d'incise — (U+2014) précédé d'une espace insécable (U+00A0) et suivi d'une espace sécable ; tiret court – (U+2013) pour les intervalles, saisi tel quel Stéphanie — Steff Beauté Plurielle
Majuscules accentuées Toujours conservées (« É », « À ») CLARTÉ, À propos

Aide typographique src/lib/typography/fr.ts (fonctions pures, testées unitairement) :

// src/lib/typography/fr.ts
export const NNBSP = '\u202F'; // espace fine insécable
export const NBSP = '\u00A0';  // espace insécable

export interface FrTypoOptions {
  /** Convertit ' en ’ (défaut : true). Désactivé pour les citations verbatim. */
  apostrophes?: boolean;
  /** Convertit ... en … (défaut : true). */
  ellipsis?: boolean;
}

/** URL et adresses e-mail : jamais modifiées (un « ? » ou un « : » y fait partie de la syntaxe). */
const PROTECTED = /(?:https?:\/\/|www\.)\S+|\S+@\S+/gu;
const SPACES = '[ \\u00A0\\u202F]*';

export function frTypo(input: string, opts: FrTypoOptions = {}): string {
  const { apostrophes = true, ellipsis = true } = opts;
  const stash: string[] = [];
  let s = input.replace(PROTECTED, (m) => `\uE000${stash.push(m) - 1}\uE001`);
  if (ellipsis) s = s.replace(/\.{3}/g, '…');
  if (apostrophes) s = s.replace(/(\p{L})'(\p{L})/gu, '$1’$2');
  // Ponctuation haute : jamais en début de chaîne, jamais entre deux signes hauts (« ?! » reste collé).
  s = s.replace(new RegExp(`(?<=[^\\s;!?])${SPACES}([;!?])`, 'gu'), `${NNBSP}$1`);   // ; ! ?  -> U+202F
  s = s.replace(new RegExp(`(?<=[^\\s:])${SPACES}:(?=\\s|$)`, 'gu'), `${NBSP}:`);         // :      -> U+00A0 (« 12:30 » intact)
  // Guillemets français.
  s = s.replace(new RegExp(`«${SPACES}`, 'gu'), `«${NNBSP}`).replace(new RegExp(`${SPACES}»`, 'gu'), `${NNBSP}»`);
  // Tiret d'incise.
  s = s.replace(new RegExp(`${SPACES}—${SPACES}`, 'gu'), `${NBSP}— `);
  // Pourcentages et nombres suivis d'un mot court / d'une unité.
  s = s.replace(new RegExp(`(\\d)${SPACES}%`, 'gu'), `$1${NBSP}%`);
  s = s.replace(/(\d\+?) (?=\p{L}{1,10}(?!\p{L}))/gu, `$1${NBSP}`);
  return s.replace(/\uE000(\d+)\uE001/g, (_, i: string) => stash[Number(i)] ?? '');
}

/** Inverse pour les métadonnées SEO et attributs techniques : remplace U+00A0/U+202F par une espace normale. */
export function stripFrTypo(input: string): string {
  return input.replace(/[\u00A0\u202F]/g, ' ');
}

Règles d'application :

  1. Le chargeur de contenu (getContent, voir §9.3) applique frTypo récursivement à toutes les chaînes sauf les clés listées dans TYPO_SKIP_KEYS = ['id','href','url','src','email','phoneE164','whatsappE164Digits','slug','lastUpdated','alt']. Pour la clé quote des témoignages, le chargeur appelle frTypo(text, { apostrophes: false, ellipsis: false }) : le texte validé par Malka reste verbatim, seules les espaces avant la ponctuation haute sont rendues insécables. alt est exclu uniquement parce que les textes alternatifs sont lus par les lecteurs d'écran : ils conservent des espaces normales.
  2. Les messages issus de la validation (schéma Zod partagé, voir §9.4) et les libellés dynamiques sont passés par frTypo dans les composants FormField et Alert.
  3. Les métadonnées (title, description, Open Graph, JSON-LD) utilisent stripFrTypo (voir §11.1) : les extraits des moteurs de recherche affichent des espaces normales.
  4. whatsappMessage traverse frTypo au chargement comme tout autre texte ; le constructeur d'URL wa.me (§9.8) l'encode ensuite tel quel (U+00A0 → %C2%A0, U+202F → %E2%80%AF), sans jamais le modifier.
  5. Un test unitaire tests/unit/typography/fr.test.ts couvre au minimum : Prêt ?, Bienvenue !, Contact : x, « mot », 6 mois, l'entreprise, https://exemple.fr/page?x=1, contact@exemple.fr, 12:30, Quoi ?!, une chaîne commençant par ?, Nom — Titre, ... et l'idempotence (frTypo(frTypo(x)) === frTypo(x)).
  6. Si la police ne contient pas le glyphe U+202F, le navigateur utilise la police de repli pour cet espace : aucun « tofu » n'est visible (c'est une espace).

5.6 Espacement, grille et conteneurs #

5.6.1 Échelle d'espacement (base 4 px) #

--spacing: 0.25rem (Tailwind) ; échelle recommandée pour les décisions de design :

Token Valeur Usage typique
space-1 4 px Écart icône/texte serré
space-2 8 px Écart interne de badge
space-3 12 px Écart label/champ
space-4 16 px Padding de bouton compact, gap de liste
space-6 24 px Gouttière mobile, padding de carte compact
space-8 32 px Padding de carte
space-10 40 px Gouttière tablette
space-12 48 px Écart entre blocs d'une section
space-16 64 px Gouttière desktop, grands écarts
space-20 80 px Écart titre de section / contenu
space-24 96 px Padding vertical de section (minimum)
space-32 128 px Séparation de blocs éditoriaux
space-40 160 px Padding vertical de section (grand écran)

Espace vertical de section (token --spacing-section) : clamp(4.5rem, 2.5rem + 8vw, 9rem) (72 px → 144 px). Utilitaire : py-section, utilisé par toutes les sections (Section, §5.12.1) : aucune section ne déclare de remplissage vertical en pixels propre. Espace entre titre de section et contenu : clamp(2.5rem, 1.5rem + 4vw, 5rem) (--spacing-block, utilitaire gap-block / mt-block).

5.6.2 Grille et conteneurs #

Élément Valeur
Points de rupture (Tailwind par défaut) sm 640 · md 768 · lg 1024 · xl 1280 · 2xl 1536. Mobile < 768, tablette 768–1023, desktop ≥ 1024. Largeur minimale supportée : 320 px.
Largeur de contenu maximale 1280 px (boîte de contenu, gouttières exclues)
Gouttières latérales 24 px (< 768) · 40 px (768–1023) · 64 px (≥ 1024)
Grille 12 colonnes à partir de 1024 px, column-gap 24 px (≥ 1280 : 32 px) ; 4 colonnes < 768 px, 8 colonnes de 768 à 1023 px
Conteneur de lecture 720 px (max-w-prose-fr) pour pages légales et textes longs
Conteneur étroit 960 px (max-w-narrow) pour formulaire de contact
Grille du hero (≥ 1024 px) Contenu 1280 px (gouttières de 64 px en plus), deux colonnes minmax(0, 7fr) minmax(0, 5fr) avec column-gap: 24px : texte à gauche, portrait à droite (détail en §6.2.2). À 1024 / 1280 / 1440 px : colonne de texte 509 / 658 / 733 px, colonne du portrait 363 / 470 / 523 px
@utility container-page {
  width: 100%;
  margin-inline: auto;
  padding-inline: var(--gutter);
  max-width: calc(var(--container-content) + 2 * var(--gutter));
}
:root { --gutter: 1.5rem; }
@media (min-width: 768px)  { :root { --gutter: 2.5rem; } }
@media (min-width: 1024px) { :root { --gutter: 4rem; } }

Les éléments pleine largeur (paysages, voiles) sortent du conteneur (position: absolute; inset: 0 dans la section) ; le contenu éditorial, lui, reste dans container-page.

5.6.3 Hauteurs fixes #

Variable Valeur Usage
--header-h 4rem (64 px) ; 4.5rem (72 px) à partir de 1024 px Hauteur du header sticky
--hero-min-h clamp(720px, 100svh, 1080px) à partir de 1024 px (jamais 100vh) ; en dessous de 1024 px : aucune hauteur minimale (hauteur du contenu) Hauteur du hero (valeur de repli min-height: 720px déclarée avant)
--hero-edge-h clamp(96px, 14svh, 160px) Hauteur de la transition du bas du hero (§5.4.2)
--footer-overlap 48 px (< 768 px) · 64 px (768–1023 px) · 80 px (≥ 1024 px) Chevauchement de la carte flottante du pied de page sur la jonction Contact → Footer (§5.12.14, §6.10.4)
scroll-margin-top des sections calc(var(--header-h) + 1rem) Voir §4.2 et §11.2.6
scroll-padding-top de html calc(var(--header-h) + 1rem) Défilement au focus clavier (WCAG 2.4.11)

5.7 Rayons, ombres, bordures, empilement #

5.7.1 Rayons #

Token Valeur Utilitaire Usage
radius-pill 9999px rounded-pill Boutons, chips, badges, pastilles
radius-card 20px rounded-card Cartes (offres, témoignages, carte CTA flottante)
radius-input 12px rounded-input Champs, zones de texte, select
radius-image 20px rounded-card Portrait « À propos » (4:5)

Le portrait détouré du hero n'a jamais de rayon (silhouette naturelle).

5.7.2 Ombres et élévation #

Ombres teintées forest-950, jamais noires. Principe : les ombres ne sont pas animées (animation interdite de box-shadow ; voir §8). Un survol « élève » une carte via la classe .motion-lift (§5.12, définie en §8) : un pseudo-élément ::after portant l'ombre cible apparaît en fondu (opacity) et la carte monte de translateY(-4px) (valeur unique de tout le site).

Token Valeur Utilitaire Usage
elev-0 none shadow-none À plat
elev-1 0 1px 2px rgb(14 34 25 / 0.06), 0 1px 1px rgb(14 34 25 / 0.04) shadow-elev-1 Champs, chips au repos
elev-2 0 4px 12px -2px rgb(14 34 25 / 0.10), 0 2px 4px rgb(14 34 25 / 0.05) shadow-elev-2 Cartes au repos
elev-3 0 12px 32px -8px rgb(14 34 25 / 0.18), 0 4px 8px rgb(14 34 25 / 0.06) shadow-elev-3 Cartes au survol, menu mobile
elev-4 0 24px 64px -16px rgb(14 34 25 / 0.30), 0 8px 16px rgb(14 34 25 / 0.08) shadow-elev-4 Carte CTA flottante, dialog
glow-champagne 0 0 0 1px rgb(212 180 122 / 0.35), 0 8px 32px -8px rgb(212 180 122 / 0.35) shadow-glow-champagne Carte featured sur thème sombre

5.7.3 Bordures #

Token Valeur Usage
Épaisseur de base 1 px Cartes, séparateurs, champs
border-line var(--border) (§5.2.2) Cartes, séparateurs, header scrollé
border-field var(--field-border) Contour des champs (≥ 3 : 1)
border-error error (clair) Champ invalide, alerte d'erreur
Focus outline: 2px solid var(--focus); outline-offset: 2px Voir §5.7.5

En forced-colors: active, les bordures transparentes sont remplacées par ButtonText/CanvasText (règle de la couche base, §5.11).

5.7.4 Échelle d'empilement (z-index) #

Deux échelles, toutes deux nommées. Aucune valeur de z-index brute n'est écrite ailleurs : un composant utilise toujours une variable de ce tableau.

Échelle globale (page) :

Variable Valeur Élément
--z-base 0 Fonds de section, flux normal
--z-content 1 Contenu de section au-dessus de ses décors locaux (chaque section crée son contexte avec isolation: isolate)
--z-header 40 Header sticky
--z-menu 50 Panneau du menu mobile (plein écran, au-dessus du header)
--z-skip 60 Lien d'évitement

Échelle locale du hero (valable à l'intérieur du contexte d'empilement du hero, de bas en haut ; empilement détaillé en §5.4.2) :

Variable Valeur Élément
--z-hero-far 0 Paysage lointain
--z-hero-mid 1 Vallée / brume
--z-hero-scrim 2 Voile de lisibilité (--scrim-hero-x)
--z-hero-word 3 Mot « CLARTÉ »
--z-hero-halo 4 Halo
--z-hero-portrait 5 Portrait détouré
--z-hero-near 6 Crête proche (devant le bas du portrait)
--z-hero-veil 7 Voile d'entrée (section 8)
--z-hero-grain 8 Grain
--z-hero-content 10 Contenu texte et contrôles
--z-hero-edge 11 Bande de transition vers la section suivante

Les sections Méthode et Footer n'ont pas d'échelle locale nommée : elles emploient --z-base (décors) et --z-content (contenu) dans un contexte isolation: isolate, l'ordre du DOM départageant les décors entre eux (4.5.2) ; aucune ne dépasse --z-header. Le menu mobile est un panneau position: fixed plein écran (role="dialog", section 6.1) placé à --z-menu : il recouvre le header et possède sa propre barre (logotype + bouton de fermeture).

5.7.5 Anneau de focus #

:where(a, button, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: inherit;
}

L'anneau utilise outline (et non box-shadow) pour rester visible en mode contraste élevé (forced-colors). Il n'est jamais supprimé (outline: none interdit sauf sur les cibles de focus programmatique non interactives : section[tabindex="-1"], main[tabindex="-1"], [data-focus-target]).

5.8 Iconographie #

Règle Valeur
Bibliothèque lucide-react (voir §9.1), icônes importées nommément (import { ArrowRight } from 'lucide-react'), jamais l'index complet ni DynamicIcon
Trait strokeWidth={1.5} partout (valeur par défaut imposée par le wrapper Icon)
Tailles 16 px (inline, texte caption) · 20 px (boutons, champs) · 24 px (IconButton, menu) · 32 px (illustrations d'état) ; jamais d'autres tailles
Couleur currentColor uniquement ; aucune icône multicolore
Accessibilité Décorative par défaut : aria-hidden="true" focusable="false". Une icône seule dans un bouton ne porte pas de nom : le nom est sur le bouton (aria-label ou texte VisuallyHidden)
Marques Lucide n'a pas de logo WhatsApp : l'action WhatsApp utilise MessageCircle + libellé texte « WhatsApp » ; aucun logo de marque tiers n'est copié

Wrapper src/components/ui/Icon.tsx :

import type { LucideIcon } from 'lucide-react';

export type IconSize = 16 | 20 | 24 | 32;

export interface IconProps {
  icon: LucideIcon;
  size?: IconSize;           // défaut 20
  className?: string;
}

export function Icon({ icon: Glyph, size = 20, className }: IconProps) {
  return <Glyph size={size} strokeWidth={1.5} aria-hidden="true" focusable="false" className={className} />;
}

Jeu d'icônes autorisé (toute autre icône exige l'ajout à ce tableau) :

Icône Lucide Usage
ArrowRight, ArrowUpRight Flèches de boutons, liens externes
ArrowDown Indice de défilement du hero (décoratif)
ArrowUp Lien « Retour en haut » du pied de page
Menu, X Ouverture / fermeture du menu mobile
ChevronLeft, ChevronRight Précédent / suivant du slider de témoignages
ChevronDown Flèche du Select
Check Case cochée, étape terminée
MessageCircle Action WhatsApp
Mail, Phone, MapPin Coordonnées (footer, contact)
Quote Guillemet décoratif de témoignage (24/32 px, champagne-600, aria-hidden)
LoaderCircle Indicateur de chargement (rotation CSS, désactivée en mouvement réduit)
CircleAlert, CircleCheck, Info Alertes erreur, succès, information
TriangleAlert Pictogramme de la page d'erreur système (§6.11)

5.9 Imagerie #

5.9.1 Principes #

  1. Uniquement des photographies réelles de Malka pour son visage. Aucun visage généré ou retouché par IA ; aucune retouche qui modifie l'identité (la colorimétrie et le détourage sont autorisés).
  2. Paysages : licence couvrant l'usage web commercial (justificatif conservé hors dépôt) ou création originale. Les crédits éventuels exigés par la licence apparaissent dans les mentions légales (voir §6.11).
  3. Aucune photographie de client, aucun faux portrait (voir §6.8).
  4. Formats : Malka fournit un seul master par emplacement, déposé tel quel dans public/images/<dossier>/ : WebP sans perte (lossless) pour les calques du hero et le portrait détouré, avec canal alpha conservé là où le tableau de §5.9.2 l'exige ; WebP de qualité ≥ 90 pour les autres visuels. Aucune variante par largeur n'est préparée : next/image (avec sharp au runtime) génère à la demande AVIF en priorité, puis WebP, aux largeurs de deviceSizes (voir §11.3.3). Les masters ne sont jamais recompressés avec perte avant dépôt.
  5. Texte alternatif : décoratif (alt="") pour tous les paysages, voiles et calques ; informatif pour les deux portraits, texte court factuel sans « photo de » ni « image de » (valeur dans les fichiers de contenu, voir §9.3 ; texte par défaut en §7).

5.9.2 Emplacements (slots) et dimensions #

Les dimensions sont des minimums : un master plus grand de même ratio est accepté. Les contrôles de dimensions, de ratio et d'alpha ci-dessous sont des gardes de production exécutées par scripts/check-assets.ts (voir §1 et §9.3) ; en développement, les emplacements acceptent les images de remplacement SVG.

Slot (id) Rôle Master livré (px, minimum) Ratio Alpha Fichier Affichage
hero-far Ciel + crêtes (calque lointain) 3840 × 1920 2 : 1 non public/images/hero/hero-far.webp ≥ 1024 px, plein cadre, débordement vertical de parallaxe
hero-mid Vallée + brume (calque médian) 3840 × 1920 2 : 1 oui public/images/hero/hero-mid.webp ≥ 1024 px
hero-near Crête proche 3840 × 1920 2 : 1 oui public/images/hero/hero-near.webp ≥ 1024 px
hero-mobile Paysage aplati (une seule image, format paysage) 1600 × 1000 16 : 10 non public/images/hero/hero-mobile.webp < 1024 px, uniquement dans la bande encadrée du hero (object-fit: cover) ; jamais derrière du texte
hero-portrait Portrait détouré de Malka 1920 × 2400 4 : 5 oui public/images/hero/hero-portrait.webp Toutes tailles
about-portrait Second portrait (rectangulaire) 1600 × 2000 4 : 5 non public/images/about/about-portrait.webp Section À propos
og-default Image de partage 1200 × 630 1,905 : 1 non public/og/og-default.jpg Open Graph / Twitter
app-icon Favicon SVG 1 : 1 oui src/app/icon.svg Onglet
apple-icon Icône écran d'accueil 180 × 180 1 : 1 non src/app/apple-icon.png iOS

Le décor du pied de page (montagnes et rivière) est entièrement vectoriel (SVG en ligne, section 6.10) : il n'a aucun emplacement d'image, aucun fichier à fournir et aucune entrée de licence.

Les images de remplacement de développement sont public/images/dev-fallback/{hero-far,hero-mid,hero-near,hero-mobile,hero-portrait,about-portrait}.svg : abstraites, sans visage, aux mêmes ratios que les masters ; elles sont rendues avec unoptimized (le SVG ne passe pas par l'optimiseur). Toute référence à dev-fallback dans un fichier de contenu fait échouer le build de production (garde d'assets, voir §1 et §9.3). Chaque visuel livré (hors images de remplacement) figure dans le registre de licences versionné docs/assets-licences.json (origine, licence, référence, date de vérification) ; une entrée manquante fait échouer le build de production.

Règles de remplacement : tout fichier d'image remplacé change de nom (suffixe -v2, -v3…) car les URL servies sont mises en cache un an (§11.3.8). Le fichier de contenu qui le référence est modifié dans le même commit.

L'image og-default.jpg (JPEG, ≤ 300 Ko, profil sRGB) est livrée par Malka ou composée à partir de ses visuels validés : zone de sécurité centrale de 1000 × 500 px (aucun texte ni visage hors de cette zone), contenu porteur = nom « Malka Debleds » en Fraunces et fond forêt/champagne. Elle ne contient aucun visage généré. Version de développement : dégradé forêt/champagne avec le mot-symbole, sans visage.

5.9.3 Direction artistique et zones de sécurité #

Direction artistique des paysages : panorama de montagnes au coucher du soleil, ombres vert forêt à gauche, lumière champagne à droite, vallée brumeuse, ligne d'horizon entre 55 % et 68 % de la hauteur du master, pic principal entre 55 % et 85 % de la largeur. La moitié gauche (0–45 %) est une zone calme et sombre (peu de détails, valeurs ≤ forest-800 en luminosité moyenne) qui accueille le texte ; le voile de §5.4 garantit le reste.

Cadrage du portrait détouré (hero-portrait, ratio 4 : 5, master ≥ 1920 × 2400 ; les pourcentages ci-dessous sont indépendants de la taille) :

┌───────────── largeur 100 % ───────────┐
│ marge transparente haute ≥ 6 %        │
│        ┌──────────────┐               │
│        │  tête        │ ← sommet des cheveux à 6–9 % │
│        │  (yeux à 22–28 % de la hauteur)             │
│        └──────────────┘               │
│   épaules et buste jusqu'à 100 %     │
│ bas : fondu alpha sur les 12 derniers % │
│ marges latérales transparentes ≥ 8 % │
└──────────────────────────────────────┘
  • Visage dans le tiers supérieur ; yeux entre 22 % et 28 % de la hauteur ; aucune partie de la tête à moins de 6 % du bord haut ; marges latérales transparentes ≥ 8 % de chaque côté.
  • Détourage : bord net de 1 à 2 px de dégradé, sans liseré blanc ou clair (nettoyage de la frange en prémultipliant contre forest-900), cheveux préservés, profil colorimétrique sRGB.
  • Le bas du portrait est fondu en CSS : mask-image: linear-gradient(to bottom, #000 82%, transparent 100%) (et -webkit-mask-image), de sorte que la crête proche (hero-near) s'y superpose sans coupure visible.
  • Aucune ombre portée CSS (filter: drop-shadow) sur le portrait ; l'ancrage visuel vient du halo (§5.4) et d'une ombre pré-cuite si Malka la valide.

Zone de sécurité du portrait « À propos » (4:5) : visage dans les 55 % supérieurs de la hauteur, object-position: 50% 30%, aucun élément essentiel à moins de 8 % des bords ; le ratio 4:5 est conservé à toutes les largeurs (pas de recadrage carré sur mobile).

Zones de sécurité du hero selon les largeurs :

Largeur Image de fond Recadrage Position (object-position)
< 1024 px hero-mobile (16:10), dans la bande encadrée uniquement (rapport 16:10 de 768 à 1023 px, 9:10 en dessous de 768 px) object-fit: cover 62% 50%
1024–1919 px hero-far (2:1) object-fit: cover 70% 60%
≥ 1920 px hero-far object-fit: cover 70% 60% ; hauteur du hero plafonnée par --hero-min-h (clamp(720px, 100svh, 1080px), §5.6.3)

Les calques de parallaxe (≥ 1024 px) dépassent de leur cadre, vers le haut uniquement (les calques se translatent vers le bas), de leur propre amplitude maximale de déplacement (top: calc(-1 * var(--parallax-extend)); height: calc(100% + var(--parallax-extend)), --parallax-extend = amplitude du calque définie en section 8 : 120 px pour hero-far, 64 px pour hero-mid, 24 px pour hero-portrait, 0 pour hero-near).

5.9.4 Règles de rendu #

Règle Détail
Composant next/image uniquement (pas de <img> nu), y compris les calques du hero rendus en <picture> via getImageProps (§11.3.3) ; seul hero-far est prioritaire (fetchPriority="high" et préchargement)
Espace réservé Toute image a width/height ou un conteneur à aspect-ratio : zéro décalage (§11.3.7)
alt Voir §5.9.1 point 5
object-fit cover pour paysages et portrait « À propos » ; contain interdit
Coins Voir §5.7.1
Filtres CSS Aucun filtre CSS sur les images (pas de grayscale, blur statique) ; flou autorisé uniquement par la section 8, desktop, ≤ 8 px
Alpha hero-mid, hero-near, hero-portrait : pas de placeholder="blur" (un rectangle flou apparaîtrait)

5.10 Thèmes sombre / clair par data-theme #

5.10.1 Contrat #

  • Valeurs : data-theme="dark" ou data-theme="light".
  • Posé sur l'élément de section (<section data-theme="dark">), sur header/footer, ou sur n'importe quel sous-bloc qui inverse localement (carte featured, carte CTA flottante).
  • L'attribut est hérité par cascade : les variables sémantiques de §5.2.2 changent d'un coup ; les composants ne contiennent aucune logique de thème en JavaScript.
  • Valeur par défaut sur :root : light. Il n'existe pas de mode sombre piloté par prefers-color-scheme (hors périmètre) ; color-scheme: light est déclaré sur :root (les contrôles natifs restent clairs).
Zone data-theme Fond Notes
Header dark transparent → forest-950 Toujours sombre, quelle que soit la section survolée
Hero (#accueil) dark forest-950 + images —
Philosophie (#philosophie) light ivory-50 —
Méthode (#methode) dark forest-900 —
Accompagnements, Orientation, À propos, Témoignages, Contact light ivory-50 / ivory-100 en alternance Voir §5.10.2
Footer dark forest-950 —
Carte d'offre featured dark (imbriqué) forest-900 Dans section claire
Carte CTA flottante light (imbriqué) ivory-50 Dans footer sombre

5.10.2 Alternance des fonds clairs #

La section claire porte data-tone="a" (ivory-50) ou data-tone="b" (ivory-100). La page src/app/page.tsx calcule le ton sur la liste des sections effectivement rendues : la première section claire rencontrée reçoit a, la suivante b, et ainsi de suite en alternance. Ainsi, si la section Témoignages est absente, l'alternance reste continue. data-tone règle uniquement --surface et --surface-alt :

[data-theme="light"][data-tone="a"] { --surface: var(--color-ivory-50);  --surface-alt: var(--color-ivory-100); }
[data-theme="light"][data-tone="b"] { --surface: var(--color-ivory-100); --surface-alt: var(--color-ivory-50); }

Les cartes posées sur la section utilisent --surface-alt pour se détacher subtilement du fond ; les champs de formulaire utilisent ivory-50 en toute circonstance (bordure sage-600 : 4,46 : 1 sur ivory-50, 4,09 : 1 sur ivory-100).

5.10.3 Définition CSS #

:root,
[data-theme="light"] {
  --surface: var(--color-ivory-50);
  --surface-alt: var(--color-ivory-100);
  --surface-raised: var(--color-ivory-50);
  --fg: var(--color-ink-900);
  --fg-strong: var(--color-forest-900);
  --fg-muted: var(--color-ink-600);
  --accent: var(--color-champagne-600);
  --eyebrow: var(--color-turquoise-600);
  --link: var(--color-turquoise-600);
  --link-hover: var(--color-forest-900);
  --border: rgb(23 32 27 / 0.14);
  --border-strong: var(--color-forest-800);
  --field-border: var(--color-sage-600);
  --focus: var(--color-turquoise-600);
  color-scheme: light;
}
[data-theme="dark"] {
  --surface: var(--color-forest-900);
  --surface-alt: var(--color-forest-950);
  --surface-raised: var(--color-forest-800);
  --fg: var(--color-ivory-50);
  --fg-strong: var(--color-ivory-50);
  --fg-muted: var(--color-sage-200);
  --accent: var(--color-champagne-400);
  --eyebrow: var(--color-champagne-400);
  --link: var(--color-champagne-400);
  --link-hover: var(--color-champagne-200);
  --border: rgb(251 248 241 / 0.16);
  --border-strong: var(--color-sage-200);
  --field-border: var(--color-sage-200);
  --focus: var(--color-champagne-400);
}

Le texte posé par défaut sur toute section est color: var(--fg); background-color: var(--surface) (appliqué par l'utilitaire section-surface de la couche base).

5.11 Déclaration Tailwind complète (src/styles/globals.css) #

La configuration est CSS-first (aucun tailwind.config.* ; voir §9.1 pour la version et §12 pour l'emplacement). Le bloc ci-dessous est la déclaration complète et normative : tout token de §5.2 à §5.7 y figure.

Points d'attention :

  • --color-*: initial supprime la palette Tailwind par défaut : seules les couleurs du projet existent (et transparent, current, inherit).
  • Les polices : next/font pose déjà --font-display, --font-display-italic et --font-sans sur <html> (§5.5.1). Les clés Tailwind portent donc des noms différents (--font-heading, --font-heading-italic, --font-body) et sont déclarées en @theme inline pour référencer ces variables sans les redéfinir (une redéfinition de même nom créerait une référence circulaire).
  • Les tokens sémantiques (--color-surface, etc.) sont déclarés en @theme inline : l'utilitaire lit la variable au moment de l'usage, donc suit data-theme.
@import "tailwindcss";

/* ---------- Variantes personnalisées ---------- */
@custom-variant theme-dark  (&:where([data-theme="dark"],  [data-theme="dark"]  *));
@custom-variant theme-light (&:where([data-theme="light"], [data-theme="light"] *));

/* ---------- Primitives ---------- */
@theme {
  /* Couleurs */
  --color-*: initial;
  --color-forest-950: #0E2219;
  --color-forest-900: #13301F;
  --color-forest-800: #1B4030;
  --color-forest-700: #275640;
  --color-sage-600: #5E7A64;
  --color-sage-400: #8FA88F;
  --color-sage-200: #D3DDD0;
  --color-ivory-50: #FBF8F1;
  --color-ivory-100: #F4EEE1;
  --color-ivory-200: #E9E0CC;
  --color-champagne-600: #9A7638;
  --color-champagne-400: #D4B47A;
  --color-champagne-200: #EBD9B3;
  --color-turquoise-600: #23736F;
  --color-turquoise-400: #4FA8A2;
  --color-turquoise-200: #A9D9D3;
  --color-ink-900: #17201B;
  --color-ink-600: #44514A;
  --color-error: #A33A2B;
  --color-success: #2E6B47;

  /* Polices : réinitialisation (les familles sont déclarées en @theme inline plus bas) */
  --font-*: initial;

  /* Échelle typographique fluide (interligne / interlettrage / graisse associés) */
  --text-*: initial;
  --text-display-xl: clamp(2.25rem, 7vw, 3.25rem);   /* < 1024 px ; la valeur ≥ 1024 px est posée dans :root plus bas */
  --text-display-xl--line-height: 1.05;
  --text-display-xl--letter-spacing: -0.025em;
  --text-display-xl--font-weight: 400;
  --text-display-lg: clamp(2.125rem, 1.5893rem + 2.6786vw, 4rem);
  --text-display-lg--line-height: 1.05;
  --text-display-lg--letter-spacing: -0.02em;
  --text-display-lg--font-weight: 400;
  --text-display-md: clamp(1.875rem, 1.5536rem + 1.6071vw, 3rem);
  --text-display-md--line-height: 1.1;
  --text-display-md--letter-spacing: -0.015em;
  --text-display-md--font-weight: 400;
  --text-heading-lg: clamp(1.5rem, 1.3571rem + 0.7143vw, 2rem);
  --text-heading-lg--line-height: 1.2;
  --text-heading-lg--letter-spacing: -0.01em;
  --text-heading-lg--font-weight: 500;
  --text-heading-md: clamp(1.25rem, 1.1786rem + 0.3571vw, 1.5rem);
  --text-heading-md--line-height: 1.25;
  --text-heading-md--letter-spacing: -0.005em;
  --text-heading-md--font-weight: 500;
  --text-heading-sm: clamp(1.125rem, 1.0893rem + 0.1786vw, 1.25rem);
  --text-heading-sm--line-height: 1.3;
  --text-heading-sm--font-weight: 600;
  --text-body-lg: clamp(1.1rem, 1.0571rem + 0.2143vw, 1.25rem);
  --text-body-lg--line-height: 1.6;
  --text-body: clamp(1rem, 0.9714rem + 0.1429vw, 1.1rem);
  --text-body--line-height: 1.65;
  --text-body-sm: clamp(0.9rem, 0.8857rem + 0.0714vw, 0.95rem);
  --text-body-sm--line-height: 1.55;
  --text-eyebrow: clamp(0.8rem, 0.7857rem + 0.0714vw, 0.85rem);
  --text-eyebrow--line-height: 1.3;
  --text-eyebrow--letter-spacing: 0.16em;
  --text-eyebrow--font-weight: 600;
  --text-caption: clamp(0.8rem, 0.7857rem + 0.0714vw, 0.85rem);
  --text-caption--line-height: 1.45;
  --text-caption--letter-spacing: 0.01em;
  --text-caption--font-weight: 500;
  --text-button: clamp(0.95rem, 0.9357rem + 0.0714vw, 1rem);
  --text-button--line-height: 1;
  --text-button--letter-spacing: 0.01em;
  --text-button--font-weight: 600;
  --text-figure: clamp(2.75rem, 2.1786rem + 2.8571vw, 4.75rem);
  --text-figure--line-height: 1;
  --text-figure--letter-spacing: -0.02em;
  --text-figure--font-weight: 400;
  --text-price: clamp(2rem, 1.7857rem + 1.0714vw, 2.75rem);
  --text-price--line-height: 1.05;
  --text-price--letter-spacing: -0.01em;
  --text-price--font-weight: 500;

  /* Espacement */
  --spacing: 0.25rem;
  --spacing-section: clamp(4.5rem, 2.5rem + 8vw, 9rem);
  --spacing-block: clamp(2.5rem, 1.5rem + 4vw, 5rem);

  /* Conteneurs (max-w-content, max-w-narrow, max-w-prose-fr) */
  --container-*: initial;
  --container-content: 80rem;   /* 1280px */
  --container-narrow: 60rem;    /* 960px */
  --container-prose-fr: 45rem;  /* 720px */

  /* Rayons */
  --radius-*: initial;
  --radius-pill: 9999px;
  --radius-card: 1.25rem;       /* 20px */
  --radius-input: 0.75rem;      /* 12px */
  
  /* Ombres */
  --shadow-*: initial;
  --shadow-elev-1: 0 1px 2px rgb(14 34 25 / 0.06), 0 1px 1px rgb(14 34 25 / 0.04);
  --shadow-elev-2: 0 4px 12px -2px rgb(14 34 25 / 0.10), 0 2px 4px rgb(14 34 25 / 0.05);
  --shadow-elev-3: 0 12px 32px -8px rgb(14 34 25 / 0.18), 0 4px 8px rgb(14 34 25 / 0.06);
  --shadow-elev-4: 0 24px 64px -16px rgb(14 34 25 / 0.30), 0 8px 16px rgb(14 34 25 / 0.08);
  --shadow-glow-champagne: 0 0 0 1px rgb(212 180 122 / 0.35), 0 8px 32px -8px rgb(212 180 122 / 0.35);

  /* Courbes (miroir CSS des courbes de la section 8) */
  --ease-clarte: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-soft: cubic-bezier(0.33, 1, 0.68, 1);

  /* Points de rupture : valeurs Tailwind par défaut conservées (sm 640, md 768, lg 1024, xl 1280, 2xl 1536) */
}

/* ---------- Références dynamiques (suivent data-theme et next/font) ---------- */
@theme inline {
  --color-surface: var(--surface);
  --color-surface-alt: var(--surface-alt);
  --color-surface-raised: var(--surface-raised);
  --color-fg: var(--fg);
  --color-fg-strong: var(--fg-strong);
  --color-fg-muted: var(--fg-muted);
  --color-accent: var(--accent);
  --color-eyebrow: var(--eyebrow);
  --color-link: var(--link);
  --color-link-hover: var(--link-hover);
  --color-line: var(--border);
  --color-line-strong: var(--border-strong);
  --color-field: var(--field-border);
  --color-focus: var(--focus);

  --font-heading: var(--font-display), ui-serif, Georgia, "Times New Roman", serif;
  --font-heading-italic: var(--font-display-italic), var(--font-display), ui-serif, Georgia, "Times New Roman", serif;
  --font-body: var(--font-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

/* ---------- Variables hors thème Tailwind ---------- */
:root {
  /* Hauteurs et gouttières */
  --header-h: 4rem;
  --gutter: 1.5rem;
  --hit-min: 2.75rem;                /* 44px, cibles tactiles principales */

  /* Hauteurs de composition du hero et du footer (§5.6.3) */
  --hero-min-h: 0px;                                   /* < 1024 px : hauteur du contenu ; valeur posée plus bas à partir de 1024 px */
  --hero-edge-h: clamp(96px, 14svh, 160px);
  --footer-overlap: 48px;

  /* Empilement (§5.7.4) : échelle globale */
  --z-base: 0; --z-content: 1; --z-header: 40; --z-menu: 50; --z-skip: 60;
  /* Empilement (§5.7.4) : échelle locale du hero */
  --z-hero-far: 0; --z-hero-mid: 1; --z-hero-scrim: 2; --z-hero-word: 3; --z-hero-halo: 4;
  --z-hero-portrait: 5; --z-hero-near: 6; --z-hero-veil: 7; --z-hero-grain: 8;
  --z-hero-content: 10; --z-hero-edge: 11;

  /* Miroir CSS des durées de la section 8 (la section 8 fait foi) */
  --duration-instant: 150ms;
  --duration-fast: 250ms;
  --duration-base: 500ms;
  --duration-slow: 900ms;
  --duration-hero: 1400ms;
}
@media (min-width: 768px)  { :root { --gutter: 2.5rem; --footer-overlap: 64px; } }
@media (min-width: 1024px) {
  :root {
    --gutter: 4rem;
    --header-h: 4.5rem;
    --footer-overlap: 80px;
    --text-display-xl: clamp(2.25rem, 4.4vw, 3.75rem);   /* H1 du hero ≥ 1024 px : trois lignes fixes (§5.5.2) */
    --hero-min-h: clamp(720px, 100svh, 1080px);          /* en dessous de 1024 px : hauteur du contenu */
  }
}

/* Placés ici, dans cet ordre, immédiatement après le bloc :root ci-dessus : (1) variables de dégradés et leur bloc @supports (§5.4.1) ; (2) thèmes sémantiques :root / [data-theme] et [data-tone] (§5.10.2 et §5.10.3) ; (3) classes de surcouches .hero-scrim, .hero-halo, .hero-edge, .site-header[data-state], .word-clarte, @utility grain (§5.4.2 à §5.4.7), .honeypot (§5.12.12). */

/* ---------- Base ---------- */
@layer base {
  html {
    font-family: var(--font-body);
    color-scheme: light;
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    font-synthesis: none;                      /* jamais de faux italique ni de faux gras */
    scroll-behavior: smooth;
    scroll-padding-top: calc(var(--header-h) + 1rem);
    scrollbar-gutter: stable;
    background-color: var(--color-forest-950); /* évite un flash clair sous le hero en overscroll */
  }
  body {
    min-height: 100svh;
    background-color: var(--surface);
    color: var(--fg);
    font-size: var(--text-body);
    line-height: var(--text-body--line-height);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
  }
  h1, h2, h3, h4 { font-family: var(--font-heading); text-wrap: balance; hyphens: manual; }
  p, li, blockquote { text-wrap: pretty; }
  /* Emphase : dans le corps de texte, graisse 600 (Manrope n'a pas d'italique), sans changement de couleur ;
     dans les titres et citations d'accroche, italique de Fraunces (§5.5.1). L'accent coloré n'est appliqué
     qu'aux textes ≥ 24 px (h1, h2, citation d'accroche) : jamais sur h3, jamais dans le corps. */
  em { font-style: normal; font-weight: 600; }
  :is(h1, h2, h3, .pull-quote) em { font-family: var(--font-heading-italic); font-style: italic; font-weight: inherit; }
  :is(h1, h2, .pull-quote) em { color: var(--accent); }
  a { text-underline-offset: 0.25em; }
  ::selection { background: var(--color-champagne-200); color: var(--color-forest-950); }
  html[data-scroll-locked] { overflow: hidden; }   /* posé pendant que le menu mobile est ouvert (§6.1) */
  section[tabindex="-1"], main[tabindex="-1"], [data-focus-target] { outline: none; }
  [hidden] { display: none !important; }

  :where(a, button, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: 2px;
    border-radius: inherit;
  }

  /* Mouvement réduit (les animations pilotées par JavaScript sont gérées par la section 8) */
  @media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    *, *::before, *::after {
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.01ms !important;
      scroll-behavior: auto !important;
    }
  }

  /* Contraste élevé : bordures et mots décoratifs */
  @media (forced-colors: active) {
    .grain::after { display: none; }
    .btn, .chip, .field-control { border: 1px solid ButtonText; }
  }
  @media (prefers-contrast: more) {
    :root { --grain-opacity: 0; }
    [data-theme="dark"]  { --border: rgb(251 248 241 / 0.4); }
    [data-theme="light"] { --border: rgb(23 32 27 / 0.4); }
  }
}

/* ---------- Utilitaires ---------- */
@utility container-page {
  width: 100%;
  margin-inline: auto;
  padding-inline: var(--gutter);
  max-width: calc(var(--container-content) + 2 * var(--gutter));
}
@utility touch-target {
  min-block-size: var(--hit-min);
  min-inline-size: var(--hit-min);
}
@utility hit-area {
  position: relative;
  &::after { content: ""; position: absolute; inset: -0.25rem; }   /* étend la zone de clic de 4 px par côté (40 → 48 px) sans changer le visuel ; deux contrôles adjacents sont séparés d'au moins 8 px : les zones se touchent sans se chevaucher */
}
@utility section-surface {
  background-color: var(--surface);
  color: var(--fg);
}

Règles d'usage :

  1. Aucune classe de couleur brute (bg-forest-900, text-ivory-50) dans les composants de section : on utilise les sémantiques (bg-surface, text-fg). Les primitives ne sont autorisées que dans globals.css, dans la carte featured et dans les composants qui fixent explicitement une couleur de marque (logo).
  2. Les classes rounded-*, shadow-* autorisées sont uniquement celles des tokens ci-dessus (la réinitialisation initial fait échouer la compilation de toute autre classe de rayon/ombre non définie).
  3. Les styles hover: sont ceux de Tailwind (actifs uniquement sur appareils à survol réel, (hover: hover)) : aucun état de survol « collant » sur écran tactile.
  4. Aucune translation ni échelle au survol ou à l'appui n'est écrite en utilitaire Tailwind (hover:-translate-y-*, active:scale-*, hover:translate-x-*, hover:scale-* sont interdits, y compris dans les classes arbitraires). Tout déplacement ou mise à l'échelle d'interaction passe par les trois classes .motion-press (appui : scale(0.98)), .motion-lift (carte : translateY(-4px)) et .motion-arrow (flèche : translateX(3px)), définies et neutralisées sous prefers-reduced-motion en section 8. Les changements de couleur et d'opacité au survol utilisent librement les variantes Tailwind. Un test de recherche de classes (AC-5.12-11) refuse les utilitaires interdits.

5.12 Catalogue de composants #

Convention commune à tous les composants (dans src/components/ui/, arborescence détaillée en §12) :

  • Composants serveur par défaut ; 'use client' uniquement pour ceux marqués « îlot client » (liste fermée en §9.2.3).
  • Props typées en TypeScript strict, sans any ; className toujours accepté en dernier recours via l'utilitaire cn() de src/lib/cn.ts (clsx + tailwind-merge).
  • États : repos, survol (appareils à survol), focus-visible (anneau §5.7.5), actif (appui), désactivé (aria-disabled="true", jamais l'attribut natif disabled pour un bouton qui doit rester focalisable), chargement (aria-busy="true" + indicateur).
  • Les changements de couleur au survol passent par un pseudo-élément dont seul l'opacity varie ; aucune animation de width, height, margin, top, left (voir §8). Durées : fast (0,25 s) pour survol, instant (0,15 s) pour appui, courbe ease-clarte.
  • Transformations d'interaction : tout déplacement ou toute échelle au survol ou à l'appui passe uniquement par les classes .motion-press (appui, scale(0.98)), .motion-lift (carte, translateY(-4px)) et .motion-arrow (flèche, translateX(3px)) de la section 8, neutralisées sous prefers-reduced-motion (règle d'usage 4 de §5.11). Les tableaux d'états ci-dessous désignent ces classes ; aucun composant n'écrit de translation ou d'échelle de survol propre.
  • Classes de service : Button/ButtonLink/IconButton portent la classe btn, ChoiceChip la classe chip, les champs la classe field-control (utilisées par les règles de contraste élevé de la couche base, §5.11).
  • Cibles : ≥ 44 × 44 px pour toute cible principale (boutons primaires, navigation, champs, chips, contrôles du slider) ; ≥ 24 × 24 px au strict minimum partout ailleurs (WCAG 2.5.8), obtenu au besoin par hit-area.
export interface SectionProps {
  id: 'accueil' | 'philosophie' | 'methode' | 'accompagnements' | 'orientation' | 'a-propos' | 'temoignages' | 'contact';
  theme: 'light' | 'dark';
  tone?: 'a' | 'b';                 // uniquement si theme === 'light' (§5.10.2)
  labelledBy: string;               // id du titre de la section : `<id>-titre` (ex. `philosophie-titre`, §4.2)
  className?: string;
  children: React.ReactNode;
}
// Rend <section id data-theme data-tone aria-labelledby tabindex="-1" class="relative section-surface py-section scroll-mt-[calc(var(--header-h)+1rem)]">
// et enveloppe le contenu dans <div class="container-page">.
export interface VisuallyHiddenProps {
  as?: 'span' | 'div' | 'p' | 'h2' | 'h3';   // défaut 'span'
  children: React.ReactNode;
}
// Applique la classe Tailwind `sr-only` (position absolue, 1×1 px, clip, overflow hidden).

SkipLink (premier élément focalisable du <body>, rendu avant <header>) :

export interface SkipLinkProps { targetId?: string }   // défaut 'contenu' ; libellé « Aller au contenu »
Aspect Spécification
Repos Hors écran : position: fixed; inset-block-start: 0.75rem; inset-inline-start: 0.75rem; transform: translateY(calc(-100% - 1rem)) (reste focalisable, pas de display: none)
Focus transform: none ; style bouton primaire md sur thème sombre (champagne-400 / forest-950, 8,41 : 1), z-index --z-skip, anneau de focus décalé de 2 px
Action Place le focus sur <main id="contenu" tabindex="-1"> et fait défiler jusqu'à lui
Mouvement réduit Apparition instantanée

Hiérarchie : primaire (champagne sur forêt ; forêt sur ivoire), secondaire (fantôme), lien. Un seul bouton primaire par bloc d'action.

export type ButtonVariant = 'primary' | 'secondary' | 'link';
export type ButtonSize = 'sm' | 'md' | 'lg';

export interface ButtonBaseProps {
  variant?: ButtonVariant;            // défaut 'primary'
  size?: ButtonSize;                  // défaut 'md'
  iconStart?: LucideIcon;
  iconEnd?: LucideIcon;               // flèche par défaut sur 'primary' et 'link' : ArrowRight
  fullWidth?: boolean;                // défaut false
  className?: string;
  children: React.ReactNode;
}
export interface ButtonProps extends ButtonBaseProps,
  Omit<React.ComponentPropsWithoutRef<'button'>, 'disabled' | 'children'> {
  loading?: boolean;                  // aria-busy + indicateur + clic ignoré
  disabled?: boolean;                 // rendu en aria-disabled, jamais l'attribut natif
}
export interface ButtonLinkProps extends ButtonBaseProps,
  Omit<React.ComponentPropsWithoutRef<'a'>, 'children' | 'href'> {
  href: string;
  external?: boolean;                 // target="_blank" rel="noopener noreferrer" + texte masqué « (ouvre … dans un nouvel onglet) »
  cta?: CtaId;                        // sucre : si renseigné, le rendu délègue au composant `CtaLink` (§9.8), qui appelle track() ; sans effet si l'analytics est désactivée
}

Anatomie :

┌───────────────────────────────────────┐
│ ○ iconStart?   Label    iconEnd? →    │   hauteur 40 / 48 / 56 ; rayon pill
└───────────────────────────────────────┘
 ::before = calque de survol (opacity 0 → 1)
Propriété sm md lg
Hauteur visuelle 40 px (zone de clic portée à 44 px par hit-area) 48 px 56 px
Padding horizontal 20 px 28 px 36 px
Texte button button button (+ 1 px)
Icône 16 px 20 px 20 px
Écart icône / texte 8 px 8 px 10 px
Usage Contrôles secondaires Par défaut CTA du hero (hauteur ≥ 52 px : lg, 56 px), du contact, de la carte flottante

Couleurs par variante et par thème :

Variante Thème dark Thème light
primary fond champagne-400, texte forest-950, survol champagne-200 fond forest-900, texte ivory-50, survol forest-800
secondary fond transparent, bordure 1 px sage-200, texte ivory-50, survol : voile ivory-50 à 10 % + bordure champagne-400 fond transparent, bordure 1 px forest-800, texte forest-900, survol : voile forest-900 à 6 % + bordure champagne-600
link texte champagne-400, souligné 1 px, décalage 0,25 em texte turquoise-600, souligné 1 px

États :

État Comportement
Repos Selon tableau ci-dessus.
Survol (appareils à survol) Calque ::before en fondu (fast) ; la flèche de fin glisse de 3 px (.motion-arrow). Le bouton lui-même ne se déplace pas. link : épaisseur de soulignement 2 px (bascule immédiate), couleur --link-hover.
focus-visible Anneau §5.7.5 (2 px, décalage 2 px).
Actif (appui) .motion-press (scale(0.98) en instant) ; le calque de survol reste visible.
Désactivé aria-disabled="true" ; fond sage-200, texte ink-600 (5,96 : 1), cursor: not-allowed, pas de survol ni d'appui ; reste focalisable ; le gestionnaire onClick ne s'exécute pas.
Chargement aria-busy="true" + aria-disabled="true" ; l'icône de début est remplacée par LoaderCircle en rotation (animate-spin) ; le libellé reste en place (la largeur est stable : le texte conserve son emprise). Clics ignorés. Mouvement réduit : l'icône ne tourne pas (le libellé « … » suffit à porter l'état).
Mobile fullWidth recommandé pour les CTA de bloc < 640 px ; jamais plus de deux boutons côte à côte < 640 px (empilement vertical, écart 12 px).

Accessibilité : un <button> sans texte visible est interdit (utiliser IconButton). Si external, le texte masqué final est « (ouvre dans un nouvel onglet) » ; pour WhatsApp : « (ouvre WhatsApp dans un nouvel onglet) ». L'URL n'est jamais construite dans le composant : elle provient du module de réservation (getBookingAction, voir §9.8).

5.12.3 IconButton #

export interface IconButtonProps extends Omit<React.ComponentPropsWithoutRef<'button'>, 'children' | 'disabled'> {
  icon: LucideIcon;
  label: string;                      // OBLIGATOIRE : devient aria-label (jamais de title seul)
  size?: 'lg' | 'md' | 'sm';          // lg : 48×48 (icône 24, précédent/suivant du slider) ; md : 44×44 (icône 24) ; sm : 40×40 + hit-area 44
  variant?: 'ghost' | 'solid';        // ghost : transparent, bordure ; solid : comme Button primary
  disabled?: boolean;                 // aria-disabled
  pressed?: boolean;                  // aria-pressed si défini
}

États identiques à Button (§5.12.2). Rayon pill. Utilisations : ouverture/fermeture du menu mobile (Menu/X, taille md), précédent/suivant du slider (ChevronLeft/ChevronRight, taille lg). Les libellés accessibles sont français et décrivent l'action (« Ouvrir le menu », « Fermer le menu », « Témoignage précédent », « Témoignage suivant »). aria-expanded et aria-controls sont ajoutés par l'appelant pour le menu.

export interface NavLinkProps {
  href: string;                       // '#philosophie' sur '/', '/#philosophie' ailleurs
  label: string;
  active?: boolean;                   // true => aria-current="location"
  variant?: 'bar' | 'sheet';          // bar : header, liens en ligne à partir de 1280 px ; sheet : menu mobile (< 1280 px)
  onNavigate?: () => void;            // ferme le menu mobile
}
Aspect bar (≥ 1280 px) sheet (< 1280 px)
Texte button (Manrope 600), ivory-50 display-md (Fraunces), ivory-50
Hauteur de cible 44 px, padding horizontal 12 px 56 px, pleine largeur, séparateur 1 px --border
Actif texte champagne-400 + filet de 2 px sous le libellé (::after scaleX(1)), aria-current="location" idem, filet à gauche de 3 px
Survol filet décoratif scaleX(0 → 1) en fast (origine gauche ; indicateur du lien, non une translation du lien) voile ivory-50 à 6 %
Focus Anneau §5.7.5 idem
Mouvement réduit Filet sans transition idem

L'état actif ne repose jamais sur la couleur seule : il combine couleur, filet et aria-current. L'état actif est piloté par l'observation des sections (voir §6.1).

5.12.5 Eyebrow et Badge #

export interface EyebrowProps {
  children: React.ReactNode;
  as?: 'p' | 'span';                  // défaut 'p' ; jamais un titre
  withRule?: boolean;                 // défaut true : filet 32×1 px devant le texte
}
export interface BadgeProps {
  children: React.ReactNode;
  tone?: 'neutral' | 'accent' | 'outline';   // défaut 'neutral'
}
Composant Style
Eyebrow Texte eyebrow (MAJUSCULES, 0,16em), couleur --eyebrow (sur sombre champagne-400, sur clair turquoise-600 — uniquement sur ivory-50/ivory-100, 5,28/4,84 : 1) ; filet de 2 rem × 1 px couleur --accent (décoratif, aria-hidden), écart 12 px.
Badge neutral Pill, padding 4 × 12 px, caption, fond sage-200, texte forest-900 (10,22 : 1).
Badge accent Fond champagne-200, texte forest-900 (10,28 : 1).
Badge outline Bordure 1 px --border-strong, texte --fg.

Un Badge n'est jamais interactif et ne remplace pas un statut annoncé (le statut a un texte pleinement lu). Usage : format d'une offre (valeur de format), mention « offre suggérée » de la boussole d'orientation.

5.12.6 SectionHeading #

export interface SectionHeadingProps {
  id: string;                         // id du titre (aria-labelledby de la section)
  title: string | readonly string[];  // tableau = lignes éditoriales explicites (chaque ligne = un masque d'animation)
  eyebrow?: string;
  lead?: string;
  level?: 2 | 3;                      // défaut 2
  size?: 'display-lg' | 'display-md'; // défaut 'display-md'
  align?: 'start' | 'center';         // défaut 'start'
  as?: 'div' | 'header';              // enveloppe, défaut 'header'
}

Anatomie :

 ── EYEBROW                         (Eyebrow, §5.12.5)
 Titre sur une ou plusieurs lignes  (h2 : display-md ; lignes en masques)
 Chapô facultatif                   (body-lg, fg-muted, max 52ch)

Règles :

  1. Le titre est un unique élément <h2> (ou <h3>), même s'il est découpé en lignes. Chaque ligne est <span class="line"><span class="line-inner">…</span></span> avec une espace réelle entre deux lignes (nœud texte " ") pour que les lecteurs d'écran lisent une phrase continue.
  2. Un mot placé entre astérisques dans title (*vivante*) est rendu <em> (italique de Fraunces ; couleur d'accent sur h1/h2 seulement, §5.11) ; un nombre impair d'astérisques fait échouer le test de contenu (§9.3) ; l'astérisque n'est jamais affiché.
  3. Les masques de révélation (section 8) ajoutent padding-block: 0.12em; margin-block: -0.12em aux lignes pour ne couper ni jambages ni accents, même avec l'espacement de texte WCAG 1.4.12 ; aucune hauteur fixe sur le titre.
  4. L'état initial rendu côté serveur est visible (aucun opacity: 0 en HTML) ; l'animation n'est armée qu'après vérification d'hydratation (voir §8).
  5. align: center uniquement pour les titres ≤ 3 lignes.

5.12.7 TrustFigure #

export interface TrustFigureProps {
  value: number;                      // entier ≥ 0, provient de site.ts (trustFigures)
  suffix?: string;                    // '+' par défaut dans les données
  label: string;                      // 'personnes accompagnées'
  size?: 'md' | 'lg';                 // md : texte `figure` réduit de 20 % ; lg : `figure`
}

Anatomie : filet vertical 1 px --accent · chiffre figure (Fraunces, tabulaire) + suffixe en --accent · libellé body-sm --fg-muted.

Règle Détail
Rendu serveur La valeur finale (« 300+ ») est dans le HTML initial. Un éventuel comptage (section 8) ne démarre qu'après hydratation, une seule fois, quand l'élément est visible, et jamais en mouvement réduit.
Stabilité min-inline-size en ch égal au nombre de caractères de la valeur finale ; chiffres tabulaires ; aucun décalage pendant le comptage.
Lecteurs d'écran Le chiffre visuel est aria-hidden="true" ; un VisuallyHidden lit « Plus de 300 personnes accompagnées » (préfixe « Plus de » ajouté quand suffix === '+'). Libellé d'interface technique A11Y_LABELS_FR.plusDe = 'Plus de' (§11.2.10).
Réservation de largeur Un éventuel élément fantôme de réservation de largeur (comptage, section 8) est purement graphique : aria-hidden="true" et visibility: hidden ; il ne double jamais le texte lu. Le seul texte lu est le VisuallyHidden ci-dessus.
Données Aucune valeur n'est codée en dur ; les seules valeurs valides au lancement sont 300+ et 50+, fournies par site.ts.

5.12.8 OfferItem #

Une offre n'est pas une carte d'une grille : le site n'empile pas de rectangles identiques. OfferItem est un composant unique à trois variantes — panel (offre mise en avant), row (ligne éditoriale), cell (cellule de la disposition sans offre mise en avant) — dont l'interface (OfferItemProps), la disposition par largeur, les libellés, le préremplissage du formulaire et le comportement sont définis en §6.5. Cette section fixe son apparence et ses contrastes.

Variante Style
panel data-theme="dark" imbriqué dans une section claire ; fond forest-900, rayon 20 px, bordure 1 px champagne-400, glow-champagne, padding 40 px (≥ 768) / 32 / 28 px ; prix en champagne-200 ; bouton WhatsApp primaire champagne. Aucun badge « le plus demandé » ni autre mention de popularité n'est ajouté (aucune donnée ne le fonde) : seul le libellé « featuredLabel » du contenu (§7) est affiché.
row Sans cadre, sans ombre, sans fond propre ; séparée des autres lignes par un filet supérieur de 1 px (--border) ; titre heading-lg, prix en forest-800 ; bouton WhatsApp forest-800 / texte ivory-50.
cell Même contenu que row, en cellule à filet supérieur de 1 px.

Anatomie (identique pour les trois variantes, l'ordre de lecture est celui du DOM) :

 01  (rang, aria-hidden)        [Badge format]   [Badge durée, si fournie]
 H3  Titre de l'offre           (heading-lg)
 Description courte             (body)
 Voir le détail ▾               (<details>/<summary> natif ; liste <ul> avec Check 20 px)
 Tarif                          (libellé VISIBLE, caption)
 300 €                          (price)  |  Sur devis
 [ Écrire sur WhatsApp ]   Écrire via le formulaire

Règles :

  • Aucune hauteur imposée : les offres n'ont ni hauteur égale ni alignement forcé (align-items: stretch interdit) ; chaque bloc prend la hauteur de son contenu.
  • Le libellé « Tarif » est visible ; le montant mis en forme est rendu aria-hidden="true" et accompagné d'un VisuallyHidden « {montant} euros » pour la synthèse vocale (§11.2.10) ; « Sur devis » est lu tel quel, au style price, sans symbole monétaire. La durée (duration), si fournie, est ajoutée à un Badge ; si elle ne l'est pas, rien n'est affiché (aucune durée par défaut).
  • Les détails sont un <details> natif (résumé ≥ 44 px) dont le contenu est une liste <ul> ; le résumé d'une offre panel est ouvert dans le HTML serveur (§6.5.5).
  • États : le bloc n'est pas cliquable et ne s'élève pas au survol (pas de .motion-lift) ; une ligne row ou cell affiche, au survol et au focus interne, une barre verticale de 2 px champagne-400 à gauche (détail en §6.5.5) ; le focus s'applique aux boutons et liens internes, jamais au bloc. Les actions suivent Button (§5.12.2).
  • Nom accessible des actions : le texte visible suivi d'un VisuallyHidden : « {libellé visible} — {titre de l'offre} (ouvre WhatsApp dans un nouvel onglet) », ce qui contient le libellé visible au début (WCAG 2.5.3). Le lien vers le formulaire est href="/?offre=<id>#contact" (fonctionne sans JavaScript, §6.5.6).

5.12.9 StepButton (boussole de méthode) #

Sémantique : motif onglets (role="tablist" sur le conteneur, role="tab" sur chaque étape, role="tabpanel" sur le contenu).

export type StepId = 'ecouter' | 'explorer' | 'clarifier' | 'decider';

export interface StepButtonProps {
  stepId: StepId;
  index: 1 | 2 | 3 | 4;
  label: string;                      // 'Écouter' | 'Explorer' | 'Clarifier' | 'Décider'
  selected: boolean;
  panelId: string;                    // aria-controls : `methode-panel-<stepId>` ; l'onglet porte l'id `methode-tab-<stepId>`
  onSelect: (stepId: StepId, source: 'click' | 'hover' | 'keyboard' | 'hash') => void;
}
Aspect Spécification
Anatomie Pastille numérotée (Fraunces) + libellé heading-sm ; nœud de 56 px (48 px sous 768 px)
Repos Fond transparent, bordure 1 px sage-200, texte ivory-50 (sur forest-900)
Survol (souris/stylet) Aperçu du panneau selon la section 6.4 ; visuel : bordure champagne-400
Sélectionné Fond champagne-400, texte forest-950 (8,41 : 1), aria-selected="true", tabindex="0" ; les autres tabindex="-1" (focus itinérant)
Focus visible Anneau champagne-400
Actif (appui) .motion-press (scale(0.98) en instant)
Tactile Un appui sélectionne ; aucune dépendance au survol
Clavier Activation automatique : les flèches déplacent le focus et sélectionnent l'étape ; Début / Fin (voir §11.2.5)

Les ancres #methode-ecouter… sont des repères vides de hauteur nulle (<span id="methode-<stepId>">, §6.4) ; le rail de méthode du hero ne contient pas de StepButton : ce sont des liens d'ancre vers ces repères (voir §6.2).

5.12.10 ChoiceChip (boussole d'orientation) #

Sémantique : boutons radio natifs dans un groupe role="radiogroup" (<fieldset> + <legend>), rendus dans le HTML serveur ; le visuel est un libellé stylé. Sous @media (scripting: none), la liste statique de la section 6.6 remplace le groupe.

export interface ChoiceChipProps {
  name: string;                       // 'situation'
  value: 'decision-bloquee' | 'fatigue' | 'repositionnement' | 'transition';
  label: string;
  checked: boolean;
  onChange: (value: ChoiceChipProps['value']) => void;
  describedBy?: string;
}
Aspect Spécification
Forme Pill, hauteur 56 px, padding horizontal 20 px, body 600
Repos Fond --surface-alt, bordure 1 px --field-border (4,46 : 1 sur ivory-50), texte --fg
Survol Bordure ink-600
Sélectionné Fond forest-900, texte ivory-50 (13,47 : 1), icône Check 16 px devant le libellé (donc pas de couleur seule)
Focus visible Anneau sur le libellé (l'input est masqué visuellement mais focalisable : sr-only)
Actif .motion-press (scale(0.98) en instant)
Disposition Colonne pleine largeur < 640 px, écart 12 px ; en ligne à passage à la ligne de 640 à 1023 px ; grille 2 × 2 à partir de 1024 px

Les flèches du clavier changent la sélection (comportement natif des boutons radio). Le résultat est annoncé par la région aria-live="polite" du module (voir §6.6).

5.12.11 TestimonialCard #

export interface TestimonialCardProps {
  testimonial: PublishableTestimonial;   // type dérivé de Testimonial, §9.3 (published && consent granted && quote non vide)
  offerTitle?: string;                   // titre de l'offre si offerId renseigné
  position?: { index: number; total: number };   // pour le nom du groupe du slider
}

Anatomie :

┌────────────────────────────────────────────┐   rayon 20 · padding 40 (≥ 768) / 28 (< 768)
│  “  (Quote 32 px, champagne-600, aria-hidden)│
│  « Citation verbatim … »   (heading-md/lg)  │
│  ──                                          │
│  Stéphanie — Steff Beauté Plurielle          │   Manrope 600 + fg-muted
│  [Badge offre?]                              │
└────────────────────────────────────────────┘

Règles : sémantique <figure><blockquote><p>…</p></blockquote><figcaption>…</figcaption></figure> ; la citation est Fraunces 400, heading-md (< 768) à heading-lg (≥ 768), interligne 1,45, mesure 48ch ; la citation est affichée sans troncature ni reformulation ; aucune photo, aucun avatar, aucun monogramme, aucune note étoilée ; les guillemets français sont ajoutés par le composant autour du texte (« + U+202F … U+202F + »). États : carte statique (les transitions du slider sont définies en §6.8 et §8). Une citation plus longue que le reste étend simplement la hauteur commune du slider (les diapositives sont empilées dans une même cellule de grille).

5.12.12 Champs de formulaire : FormField, Input, Textarea, Select, Checkbox, Honeypot #

interface FieldBaseProps {
  id: string;
  name: string;
  label: string;
  hint?: string;
  error?: string;                          // message français (voir §9.4 pour les clés)
  requirement: 'required' | 'optional';    // optional => balise « facultatif »
}
export interface InputProps extends FieldBaseProps,
  Omit<React.ComponentPropsWithoutRef<'input'>, 'id' | 'name' | 'type'> {
  type: 'text' | 'email' | 'tel';
  autoComplete: 'name' | 'email' | 'tel' | 'off';
  inputMode?: 'text' | 'email' | 'tel';
}
export interface TextareaProps extends FieldBaseProps,
  Omit<React.ComponentPropsWithoutRef<'textarea'>, 'id' | 'name'> {
  maxLength: number;                       // 2000
  showCounter?: boolean;                   // défaut true
}
export interface SelectProps extends FieldBaseProps,
  Omit<React.ComponentPropsWithoutRef<'select'>, 'id' | 'name' | 'children'> {
  options: readonly { value: string; label: string }[];
}
export interface CheckboxProps {
  id: string;
  name: string;
  label: React.ReactNode;                  // peut contenir un lien vers la politique de confidentialité
  error?: string;
  checked: boolean;
  onChange: (checked: boolean) => void;
}
export interface HoneypotProps { name?: 'website' }   // défaut 'website'

Anatomie d'un champ :

 Label (body-sm 600)                    facultatif (caption, fg-muted, à droite)
 ┌─────────────────────────────────────────┐   hauteur 52 px · rayon 12 px
 │ Valeur                                  │   bordure 1 px --field-border
 └─────────────────────────────────────────┘
 Aide (caption, fg-muted)                       n / 2000 (textarea, caption, à droite)
 ⚠ Message d'erreur (caption, error)
Aspect Spécification
Étiquette Toujours visible, au-dessus du champ ; jamais un placeholder à la place d'un label. <label for> explicite.
Obligatoire / facultatif Le formulaire annonce au début (copy §7) que les champs non marqués « facultatif » sont obligatoires ; seuls les champs facultatifs portent une balise. Le champ obligatoire a aria-required="true". Le formulaire a noValidate : la validation est faite par le schéma partagé (voir §9.4), les bulles natives ne sont jamais affichées.
Taille de police max(1rem, 16px) : aucun zoom automatique iOS.
Hauteurs Input et Select 52 px ; Textarea min-height: 9rem (rows 6), resize: vertical ; case à cocher 24 × 24 px, cible 44 px par le libellé.
Placeholder Non utilisé pour porter une information (au besoin, exemple discret en ink-600, 7,85 : 1).
Autocomplétion (WCAG 1.3.5) nom : name · e-mail : email (inputmode="email") · téléphone : tel (inputmode="tel") · message : aucune.
Association aria-describedby = identifiants de l'aide, du compteur et du message d'erreur (dans cet ordre) ; aria-invalid="true" en erreur.
Compteur n / 2000 en caption, chiffres tabulaires ; non annoncé en direct ; la plage 20–2000 est dans l'aide associée.
Select <select> natif stylé (apparence réinitialisée, ChevronDown 20 px en superposition pointer-events: none) ; liste des options affichée par le système.
Case à cocher <input type="checkbox"> natif, appearance: none, boîte 24 px, bordure --field-border, coche Check ivory-50 sur forest-900 à l'état coché ; libellé cliquable ; si le libellé contient un lien vers la politique de confidentialité, ce lien s'ouvre dans un nouvel onglet avec texte masqué « (ouvre dans un nouvel onglet) » pour ne pas perdre la saisie.

États des champs :

État Apparence
Repos Fond ivory-50, bordure sage-600, texte ink-900.
Survol Bordure ink-600.
Focus Anneau §5.7.5 (turquoise-600) + bordure turquoise-600.
Renseigné Identique au repos.
Erreur Bordure error + liseré intérieur box-shadow: inset 0 0 0 1px var(--color-error) (non animé : aucune variation de taille) + icône CircleAlert 16 px + message error sous le champ.
Envoi en cours Le formulaire porte aria-busy="true" ; les champs de saisie passent en readOnly (le focus n'est jamais perdu) et restent visuellement identiques ; le <select> et la case à cocher, qui n'ont pas de mode lecture seule, ignorent tout changement tant que l'envoi dure (pointer-events: none sur leur enveloppe) ; le bouton est en état chargement (aria-disabled="true"). Aucun <fieldset disabled> n'est utilisé (il ferait perdre le focus).
Désactivé Non utilisé (aucun champ désactivé dans le MVP).

Moment d'affichage des erreurs : à la soumission pour tous les champs ; ensuite, un champ en erreur est revalidé à chaque saisie et son erreur disparaît dès qu'il est valide ; un champ non encore en erreur est validé au blur seulement s'il a été modifié. À l'échec d'une soumission, les erreurs sont aussi réunies dans le récapitulatif d'erreurs du formulaire, qui reçoit le focus ; chaque entrée du récapitulatif est un lien qui place le focus sur le champ concerné. Le déroulé complet du formulaire est défini en §6.9.

Honeypot : champ website (texte), conforme à §9.6.

// <div aria-hidden="true" inert class="honeypot"> <input type="text" name="website" tabindex={-1} autocomplete="off" /> </div>
.honeypot { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }

L'attribut inert retire le champ de l'arbre d'accessibilité, de l'ordre de tabulation et de la saisie utilisateur ; aucune étiquette lisible n'est exposée. Le champ n'est pas masqué par display: none (certains robots ignorent les champs ainsi masqués).

5.12.13 Alert (et décision sur les toasts) #

export interface AlertProps {
  variant: 'success' | 'error' | 'info';
  title: string;
  children?: React.ReactNode;              // détail, texte court
  actions?: React.ReactNode;               // ex. liens de secours WhatsApp / e-mail après EMAIL_DELIVERY_FAILED
  headingLevel?: 2 | 3 | 4;                // défaut 3
  focusOnMount?: boolean;                  // défaut false
}

Anatomie : liseré latéral de 4 px (couleur de variante) · icône 24 px (CircleCheck, CircleAlert, Info) · titre (heading-sm, --fg-strong) · texte (body, --fg) · actions. Fond --surface-alt, bordure 1 px --border, rayon 20 px, padding 24 px. Variantes : succès (liseré et icône success, 5,98 : 1), erreur (error, 6,19 : 1), information (turquoise-600). Alerte uniquement sur fond clair (§5.3.4).

Mode Rôle ARIA Usage
Région vivante (focusOnMount: false) role="status" pour success et info (et role="alert" pour une error qui ne prend pas le focus) ; le conteneur existe dans le DOM, vide, avant l'insertion du contenu Rappels d'information et messages non bloquants
Gérée par le focus (focusOnMount: true) role="group" + aria-labelledby sur le titre, tabindex="-1", le composant prend le focus au montage ; pas de rôle vivant (évite l'annonce en double) Panneau de confirmation qui remplace le formulaire après succès ; bannière d'erreur serveur ou réseau du formulaire (§6.9)

Décision : il n'y a pas de composant Toast dans le MVP. Tout statut est affiché en ligne, à l'endroit de l'action : un message éphémère disparaît avant d'être lu par certains utilisateurs (WCAG 2.2.1), peut masquer le contenu (2.4.11) et ne doit pas voler le focus. Aucun autre cas d'usage de notification transitoire n'existe dans le périmètre.

5.12.14 FloatingCtaCard #

Composant unique (src/components/ui/floating-cta-card.tsx) : la carte d'appel à l'action du pied de page. Aucune seconde implémentation n'existe.

export interface FloatingCtaCardProps {
  title: string;                           // copy §7 ; rendu dans un <p>, jamais un titre de niveau h2
  text: string;                            // copy §7, sans promesse
  whatsappLabel: string;                   // « Écrire sur WhatsApp » (copy §7)
  whatsappHref: string;                    // via getBookingAction({}) (§9.8), calculé côté serveur
  formLabel: string;                       // « Écrire via le formulaire » (copy §7)
  formHref: '/#contact';
}

Anatomie : carte data-theme="light" imbriquée (fond ivory-50) posée dans le footer sombre ; titre (<p> au style heading-lg, Fraunces), texte body, bouton primaire lg (forêt sur ivoire, 13,47 : 1) « Écrire sur WhatsApp » avec MessageCircle, puis lien texte secondaire « Écrire via le formulaire » (zone de 44 px). Rayon 20 px, elev-4, padding 32–40 px, largeur maximale 40 rem (desktop), pleine largeur moins gouttières (mobile). Le titre est un <p> pour ne pas ajouter de niveau de titre hors plan (§11.1.8).

Positionnement : « flottante » signifie chevauchant la jonction entre le contact et le footer de --footer-overlap (48 / 64 / 80 px selon la largeur, §5.6.3) : position: relative; margin-block-start: calc(-1 * var(--footer-overlap)); z-index: var(--z-content). Elle n'est jamais position: fixed ni sticky : elle ne peut donc pas masquer un élément focalisé (WCAG 2.4.11) ni gêner le défilement. La section #contact réserve la zone recouverte (padding-block-end: calc(var(--footer-overlap) + 64px)) : au moins 64 px d'espace libre subsistent entre son dernier contenu et la carte, aucun texte ne peut entrer en collision avec elle (§6.10.4). États : survol (appareils à survol) .motion-lift (translateY(-4px) en base + ombre par calque) ; le bouton suit §5.12.2.

5.12.15 Sheet (menu mobile) — dialogue plein écran #

export interface SheetProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  id: 'menu-mobile';                       // cible de aria-controls du bouton d'ouverture
  label: string;                           // aria-label du dialogue (copy §7)
  returnFocusTo: React.RefObject<HTMLElement | null>;   // bouton d'ouverture
  children: React.ReactNode;
}

Implémentation : panneau personnalisé role="dialog" aria-modal="true" (pas d'élément natif <dialog>), position: fixed; inset: 0; height: 100dvh, z-index: var(--z-menu), avec piège de focus, fermeture à Échap, verrouillage du défilement par l'attribut html[data-scroll-locked] (§5.11), retour du focus au bouton d'ouverture, aucune fermeture au clic sur l'arrière-plan (le panneau le recouvre entièrement). Le détail du comportement (inertie du reste de la page, observateur de section active) est en §6.1. Structure :

┌──────────────────────────────┐  plein écran : 100dvh × 100vw, fond forest-950 + grain
│ Logo « Malka Debleds »   [X] │  barre 64 px (même géométrie que le header) ; X = IconButton 44×44
│                              │
│ Philosophie                  │  NavLink 'sheet' (display-md), 56 px min.
│ Méthode                      │
│ Accompagnements              │
│ Orientation                  │
│ À propos                     │
│ Témoignages (conditionnel)   │
│ Contact                      │
│                              │
│ [ Faire le point  → ]        │  Button primary lg, plein largeur, marge basse safe-area
└──────────────────────────────┘
Aspect Spécification
Ouverture Le focus va sur le bouton de fermeture ; data-state="open".
Animation Entrée : opacity 0 → 1 + translateY(-12px → 0) en fast ; sortie : opacity en instant puis retrait du panneau. Mouvement réduit : ouverture et fermeture instantanées.
Fermeture Bouton X, touche Échap, activation d'un lien (le focus revient au bouton d'ouverture, sauf si l'ancre cible une section, auquel cas le focus est déplacé sur la section selon §11.2.6), passage à une largeur ≥ 1280 px (écouteur matchMedia).
Défilement html[data-scroll-locked] { overflow: hidden } ; overscroll-behavior: contain sur le panneau ; scrollbar-gutter: stable sur html évite tout saut de largeur.
Safe area padding: max(1.5rem, env(safe-area-inset-top)) max(1.5rem, env(safe-area-inset-right)) max(1.5rem, env(safe-area-inset-bottom)) max(1.5rem, env(safe-area-inset-left)).
Fond Aplat opaque forest-950 : aucun arrière-plan séparé ni voile cliquable.
export interface TextLinkProps extends Omit<React.ComponentPropsWithoutRef<'a'>, 'children'> {
  href: string;
  external?: boolean;
  children: React.ReactNode;
}

Lien dans un paragraphe : couleur --link, toujours souligné (WCAG 1.4.1), survol : couleur --link-hover et soulignement 2 px, focus : anneau §5.7.5 ; icône ArrowUpRight 16 px et texte masqué « (ouvre dans un nouvel onglet) » si external.

5.12.17 Récapitulatif des tailles de cibles #

Composant Taille visuelle Zone de clic effective
Button sm / md / lg 40 / 48 / 56 px 44 / 48 / 56 px
IconButton lg / md / sm 48 / 44 / 40 px 48 / 44 / 44 px
Points du slider de témoignages 10 px 44 × 44 px (zone de clic, écart entre points ≥ 8 px)
NavLink bar 44 px de haut 44 px
NavLink sheet 56 px de haut 56 px
StepButton 48–56 px 48–56 px
ChoiceChip 56 px 56 px
Input / Select 52 px 52 px
Case à cocher 24 px 44 px (libellé cliquable)
Lien du pied de page 24 px de haut minimum + hit-area 44 px

5.13 Critères d'acceptation du design system #

ID Critère Vérification
AC-5.1-01 Aucun littéral hexadécimal dans src/ hors globals.css et src/lib/design/tokens.ts ; aucun rgb(/hsl( hors ces deux fichiers et src/styles/motion.css. global-error.tsx importe ERROR_PAGE_COLORS et layout.tsx importe THEME_COLOR. Test unitaire §5.1.3
AC-5.1-02 globals.css contient toutes les variables de §5.11 ; la compilation Tailwind réussit sans avertissement. pnpm build
AC-5.2-01 Les 20 couleurs de §5.2.1 existent avec exactement les valeurs hexadécimales indiquées (utilitaires bg-forest-950 … bg-success). Test unitaire lisant le CSS compilé
AC-5.2-02 Aucun #FFFFFF ni #000000 dans l'interface. Test §5.1.3
AC-5.3-01 Chaque couple texte/fond réellement rendu apparaît dans les tableaux de §5.3.2, §5.3.3, §5.3.7 avec un statut AA ; aucun couple de §5.3.4 n'est utilisé. Test unitaire de contraste (calcul WCAG sur les tokens utilisés dans les composants) + revue
AC-5.3-02 Aucun texte porteur d'information n'utilise opacity < 1 ni couleur à canal alpha. Revue + recherche text-.*/\d+ interdite hors décoratif
AC-5.3-03 Les états de focus (clair et sombre) ont un contraste ≥ 3 : 1 avec l'arrière-plan adjacent. Test Playwright de contraste sur 6 composants
AC-5.4-01 Les variables de §5.4.1 existent avec les valeurs fournies ; le repli sans in oklab est présent ; --scrim-hero-y n'existe pas. Test unitaire
AC-5.4-02 Le header reste visible à toute position de défilement ; il conserve une hauteur identique (72/64 px) dans les états top, scrolled, solid ; aucun décalage au passage de l'un à l'autre ; backdrop-filter: blur(12px) seulement à partir de 768 px et seulement en état scrolled. E2E (mesure de getBoundingClientRect et des styles calculés)
AC-5.4-03 Dans le hero, pour chaque nœud de texte à 360, 768, 1024, 1440 et 1920 px, le contraste mesuré sur les pixels de fond (texte masqué, 95ᵉ centile de luminance sur la boîte + 8 px) est ≥ 4,5 : 1 (≥ 3 : 1 pour le grand texte). Test Playwright (capture sans texte + échantillonnage)
AC-5.4-04 Tout texte du footer posé sur le décor vectoriel ou la lueur est dans une zone de voile ≥ 0,80 ; la barre légale repose sur un aplat plein. Même test que AC-5.4-03 appliqué au footer
AC-5.4-05 Le mot « CLARTÉ » est aria-hidden, non focalisable, non sélectionnable ; coupé de moins de 3 % de sa largeur à 320/768/1440/1920 px ; opacité fixe 0,14 (hero et bande) et 0,08 (footer), couleur plate (aucun background-clip: text). Test unitaire (attributs et styles) + capture visuelle
AC-5.4-06 public/textures/grain.svg pèse ≤ 4 096 octets ; l'opacité du grain est comprise entre 0,03 et 0,05 ; il n'y a aucun mix-blend-mode ni grain position: fixed. Test de taille + recherche CSS
AC-5.5-01 Seules deux familles sont chargées (Fraunces romain et italique, Manrope), l'italique de Fraunces n'étant jamais préchargée (preload: false) ; font-synthesis: none est posé sur html ; aucun appel réseau vers un domaine de polices tiers à l'exécution. E2E (journal réseau + styles calculés)
AC-5.5-02 Les tokens de §5.5.2 sont déclarés avec exactement les clamp() indiqués (dont les deux valeurs de display-xl) ; aucune taille de texte rendue < 12,8 px ; à 1024, 1280 et 1440 px le H1 du hero occupe exactement trois lignes. Test unitaire + E2E (styles calculés)
AC-5.5-03 frTypo passe les cas de §5.5.6 point 5, y compris l'idempotence et l'intégrité des URL et adresses e-mail ; toutes les chaînes affichées issues des fichiers de contenu contiennent U+202F avant ; ! ? et U+00A0 avant :. Test unitaire sur le contenu chargé
AC-5.5-04 La mesure des paragraphes de corps ne dépasse pas 68 caractères en largeur ≥ 1440 px. E2E (mesure)
AC-5.6-01 La largeur de contenu ne dépasse pas 1280 px ; gouttières 24/40/64 px aux trois familles d'écran. E2E
AC-5.6-02 Aucune barre de défilement horizontale à 320 px sur aucune route. E2E
AC-5.7-01 Seuls les rayons, ombres et z-index des tokens sont employés (classes rounded-*, shadow-* hors tokens refusées à la compilation) ; aucune valeur de z-index numérique brute dans src/ hors globals.css. Build + test de recherche
AC-5.7-02 Les ombres ne sont pas animées (box-shadow absent de toute propriété transition ou animation). Test de recherche CSS
AC-5.8-01 Toutes les icônes ont stroke-width: 1.5, aria-hidden="true" et une taille parmi 16/20/24/32 ; aucune icône hors de la liste de §5.8. Test unitaire (analyse des imports) + E2E
AC-5.9-01 Chaque slot d'image de §5.9.2 respecte ses dimensions minimales, son ratio et son canal alpha (calques hero-mid, hero-near, hero-portrait) ; chaque visuel livré a une entrée dans docs/assets-licences.json ; aucun fichier de production ne référence dev-fallback ; ces contrôles s'exécutent au build (scripts/check-assets.ts), jamais au démarrage du conteneur. Garde de build (§1)
AC-5.9-02 Aucune image n'est rendue sans dimensions ou conteneur à ratio fixe. E2E (CLS ≤ 0,05)
AC-5.9-03 hero-portrait (ratio 4 : 5) : marges transparentes latérales ≥ 8 % et haute ≥ 6 %, sans liseré clair (contrôle visuel sur fond forest-900 et sur fond ivory-50). Revue visuelle de recette
AC-5.10-01 Les sections respectent le tableau de §5.10.1 ; l'alternance data-tone reste continue avec ou sans la section Témoignages. Test E2E sur les deux configurations de contenu
AC-5.10-02 Aucun composant ne contient de logique de thème en JavaScript ; le changement de thème repose uniquement sur data-theme. Revue + recherche
AC-5.12-01 Tous les composants de §5.12 acceptent les états de leur tableau ; chaque bouton désactivé reste focalisable (aria-disabled). Tests de composants (Testing Library)
AC-5.12-02 Button en chargement : aria-busy="true", clic ignoré, largeur inchangée (écart ≤ 0 px). Test de composant
AC-5.12-03 SectionHeading produit un seul <h2>/<h3> ; une chaîne à astérisque impair fait échouer le test de contenu ; l'état rendu côté serveur est visible. Tests de composant + SSR
AC-5.12-04 TrustFigure : HTML initial = « 300+ » ; lecteur d'écran = « Plus de 300 personnes accompagnées » ; aucun décalage pendant le comptage. Test SSR + E2E
AC-5.12-05 OfferItem : aucune hauteur imposée entre offres ; le libellé « Tarif » est visible ; Sur devis sans symbole monétaire ; le nom accessible de chaque action commence par le libellé visible ; aucune élévation au survol. E2E + test de composant
AC-5.12-06 FormField : label visible, aria-describedby et aria-invalid corrects, noValidate, taille de police ≥ 16 px, honeypot inert hors arbre d'accessibilité. Test de composant + axe
AC-5.12-07 Alert : conteneur vivant présent avant l'insertion ; en mode focusOnMount aucun rôle vivant et focus déplacé. Test de composant
AC-5.12-08 Sheet : role="dialog" aria-modal="true" id="menu-mobile", focus piégé, Échap ferme, focus rendu au bouton d'ouverture, aucune fermeture au clic hors des contrôles, fermeture automatique à ≥ 1280 px, défilement de page verrouillé (html[data-scroll-locked]) sans saut de largeur. E2E
AC-5.12-09 FloatingCtaCard n'est jamais fixed ni sticky ; son chevauchement vaut --footer-overlap (48 / 64 / 80 px) ; ≥ 64 px d'espace libre séparent le dernier contenu de #contact du haut de la carte ; son titre est un <p>. Test de recherche CSS + E2E
AC-5.12-10 Aucun composant Toast n'existe dans le code. Recherche
AC-5.12-11 Aucune classe hover:*translate*, hover:scale-*, active:scale-* ni équivalent arbitraire n'existe dans src/ ; les interactions de translation et d'échelle passent par .motion-press, .motion-lift, .motion-arrow, qui n'ont aucun effet sous prefers-reduced-motion: reduce. Test de recherche + E2E (reducedMotion: 'reduce')

6. Spécifications des sections et interactions #

Cette section est le contrat d'exécution de chaque bloc visible du site : ce qu'il montre, comment il se dispose à chaque largeur, d'où viennent ses contenus, quels composants le construisent, quels états il traverse et comment on l'utilise à la souris, au doigt et au clavier. Les contenus rédactionnels (textes français définitifs) sont dans §7 ; les jetons visuels dans §5 ; le moteur d'animation et les noms de variantes dans §8 ; les schémas Zod des fichiers de contenu dans §9.3 ; les ancres et identifiants dans §4.2 ; les événements de mesure dans §9.8. Cette section ne redéfinit aucun de ces éléments : elle les assemble.

Les sous-sections 6.1 à 6.5 couvrent la navigation (A), le hero (B), la philosophie (C), la méthode (D) et les accompagnements (E). Les sous-sections 6.6 à 6.11 couvrent le reste de la page.

Structure commune des sous-sections #

Chaque sous-section 6.N est découpée en onze blocs numérotés 6.N.1 à 6.N.11, toujours dans le même ordre, pour que l'exécutant retrouve l'information au même endroit :

Bloc Contenu
6.N.1 Objectif et rôle dans le parcours du visiteur
6.N.2 Disposition par breakpoint (mobile / tablette / desktop), avec wireframe ASCII si utile
6.N.3 Source de contenu (fichier de src/content/fr/, forme des données consommées, valeurs par défaut)
6.N.4 Arborescence de composants et props (TypeScript)
6.N.5 États (nominal, chargement, vide, erreur, succès, désactivé, mouvement réduit, mobile)
6.N.6 Interactions : souris, tactile, clavier
6.N.7 Accessibilité (rôles, noms accessibles, focus, contrastes)
6.N.8 Mouvement (renvoi vers §8, sans redéfinir les jetons)
6.N.9 Événements de mesure (renvoi vers §9.8)
6.N.10 Cas limites
6.N.11 Critères d'acceptation numérotés AC-6.N-NN

Conventions communes à toutes les sections de la page #

Breakpoints. Mobile : < 768 px. Tablette : 768–1023 px. Desktop : ≥ 1024 px. Les variantes Tailwind (sm 640, md 768, lg 1024, xl 1280, 2xl 1536) sont utilisées telles quelles. Largeur minimale supportée : 320 px, sans défilement horizontal. Quand une sous-section cite « ultra-large », il s'agit de ≥ 1536 px.

Hauteur d'en-tête. La variable CSS --header-h est déclarée une seule fois dans src/styles/globals.css : 64px par défaut, 72px à partir de lg. Toute section d'ancre applique scroll-margin-top: calc(var(--header-h) + 16px).

Contrat de racine de section. Chaque section de la page d'accueil rend une racine de la forme suivante (les identifiants viennent de §4.2) :

<section
  id="philosophie"
  aria-labelledby="philosophie-titre"
  tabIndex={-1}
  className="scroll-mt-[calc(var(--header-h)+16px)] outline-none"
>
  <h2 id="philosophie-titre">…</h2>
</section>

tabIndex={-1} permet de déplacer le focus par programme vers la section après une navigation interne (voir 6.1.6). Comme ce focus est programmatique, aucun anneau de focus n'est dessiné sur la racine (outline-none), mais le premier élément interactif de la section conserve son anneau normal. Le titre d'une section porte l'identifiant <identifiant de section>-titre (§4.2) ; le hero (#accueil) suit le même contrat avec un <h1 id="accueil-titre"> à la place du <h2>. Un seul <h1> existe sur la page. Après une navigation interne, le focus va toujours à la racine de la section (jamais au titre).

Composants serveur par défaut. Un composant est un Server Component sauf s'il porte 'use client'. Les îlots clients sont listés explicitement dans l'arborescence de chaque sous-section. Aucun contenu visible ne dépend du JavaScript pour exister dans le HTML initial : tout texte, tout lien et toute image de contenu sont présents dans le rendu serveur à leur état final (les états initiaux d'animation ne sont appliqués qu'après hydratation, voir §8).

Accès au contenu. La page src/app/page.tsx appelle getContent('fr') une seule fois et passe à chaque section la tranche de contenu dont elle a besoin. Les composants de section n'importent jamais directement un fichier de src/content/fr/. Les schémas Zod de §9.3.3 sont le propriétaire unique des clés de contenu, de leurs types et de leurs bornes : les tableaux « Source de contenu » de cette section indiquent l'usage de chaque clé et ses valeurs par défaut, sans redéfinir les types. Les interfaces TypeScript données ici sont uniquement des props de composants.

Cibles tactiles et focus. Toute cible interactive principale mesure au moins 44 × 44 px ; toute cible secondaire (lien en ligne dans un paragraphe) au moins 24 × 24 px (WCAG 2.2 critère 2.5.8). L'anneau de focus suit §5 : champagne-400 2 px avec décalage de 2 px sur fond sombre, turquoise-600 sur fond clair. Aucun outline: none sans équivalent visible.

Liens externes. Un lien ouvrant WhatsApp utilise target="_blank", rel="noopener noreferrer" et contient le texte masqué visuellement « (ouvre WhatsApp dans un nouvel onglet) ». Les composants n'assemblent jamais une URL WhatsApp eux-mêmes : les composants serveur appellent getBookingAction (module serveur lib/booking/index.ts, voir §9.9 et l'extension Cal.com décrite en §9) et transmettent les href obtenus aux îlots clients sous forme de props ; seuls lib/booking/types.ts et lib/booking/whatsapp.ts sont importables côté client.

Mouvement. Les durées, courbes, décalages et variantes portent les noms définis dans §8 ; cette section décrit l'intention et la cible de chaque animation, pas ses valeurs numériques, sauf lorsqu'une valeur est une propriété de composition (par exemple la répartition du parallaxe par couche du hero). Sous prefers-reduced-motion: reduce, le comportement par défaut de §8 s'applique à tout ce qui n'est pas explicitement précisé.

Rythme vertical. Le padding-block vertical de chaque section est le jeton --spacing-section de §5.6.1 (classe py-section), sans valeur en pixels propre à une section. Les fondus d'ouverture et de fermeture de section décrits plus bas sont dans la section et ne déplacent rien.

Superpositions. L'ordre des couches utilise exclusivement les jetons --z-* de §5.7.4 (globaux : --z-base, --z-content, --z-header, --z-menu, --z-skip ; locaux au hero : --z-hero-*) ; aucune valeur numérique de z-index n'est écrite dans un composant.

Noms de fichiers et de composants. Les chemins cités sont ceux de §12.2 (fichiers en kebab-case) ; chaque composant porte le nom PascalCase de son export. Le lien d'action avec mesure s'appelle CtaLink (components/ui/cta-link.tsx).

Mesure. Les appels track() sont décrits dans chaque sous-section (bloc 9) avec le nom d'événement et les propriétés de §9.8. track() est sans effet quand ANALYTICS_PROVIDER=none.

Notation des couleurs. forest-950/90 signifie le jeton forest-950 (§5) à 90 % d'opacité, exposé par Tailwind sous la classe bg-forest-950/90. champagne-600 n'est utilisé comme texte que pour du texte large (≥ 24 px, ou ≥ 18,66 px en gras) ou comme élément graphique, car son rapport de contraste sur ivory-50 est d'environ 3,9:1 ; le texte courant sur fond clair utilise ink-900, ink-600 ou forest-700.

Repli sans JavaScript. Les règles de repli sans JavaScript sont écrites dans src/styles/globals.css sous @media (scripting: none) { … } (aucun <noscript> ni style en ligne). Sur un navigateur qui ne connaît pas cette requête média, le site reste utilisable : les sections sont toutes rendues, seule la présentation améliorée par JavaScript est absente.

6.1 Navigation premium (A) #

6.1.1 Objectif et rôle dans le parcours #

La navigation est discrète et reste toujours à portée : elle s'efface sur le paysage du hero (fond transparent), devient un bandeau forêt translucide dès que l'on quitte le haut de page, et demeure visible à toute position de défilement, en défilant vers le bas comme vers le haut. Elle remplit quatre rôles : orienter (liens d'ancres et section active), identifier (logotype « Malka Debleds »), convertir (bouton « Faire le point » toujours à portée) et dépanner (menu mobile accessible, variante pour les pages légales).

Hauteur : --header-h (72 px à partir de lg, 64 px en dessous). L'en-tête est collé en haut de la fenêtre (position: fixed; top: 0) : il ne pousse jamais le contenu ; le hero commence en haut de page et réserve lui-même l'espace de l'en-tête (voir 6.2.2). Il ne se retire jamais : aucune logique de masquage au défilement n'existe.

6.1.2 Disposition par breakpoint #

Largeur Barre d'en-tête Menu
< 640 px Logotype à gauche, bouton burger 44 × 44 à droite. Le CTA n'est pas dans la barre. Panneau plein écran (6.1.6) contenant liens + CTA + coordonnées
640–1279 px Logotype à gauche ; à droite CTA compact « Faire le point » (hauteur 44) puis burger 44 × 44 Panneau plein écran
≥ 1280 px (xl) Logotype à gauche ; liens en ligne ; CTA à droite ; pas de burger Aucun

Décision : les liens passent en ligne à partir de xl (1280 px) et non à lg. Raison : avec Témoignages affiché, sept liens de 14 px, le logotype et le CTA mesurent environ 1 040 px de large ; à 1024 px le contenu utile n'en offre que 896. Entre 1024 et 1279 px, l'en-tête garde donc sa hauteur de 72 px mais utilise le burger (avec le CTA compact visible dès 640 px).

Wireframe ≥ 1280 px (état scrolled) :

┌──────────────────────────────────────────────────────────────────────────────┐
│ Malka Debleds    Philosophie Méthode Accompagnements Orientation À propos     │
│ (Fraunces 22)    Témoignages Contact                       [ Faire le point →]│
└──────────────────────────────────────────────────────────────────────────────┘
 hauteur 72 · conteneur max 1280 · gouttières 64 · gap entre liens 20 · CTA hauteur 44

Wireframe < 640 px, menu ouvert :

┌──────────────────────────┐
│ Malka Debleds        [ ✕ ]│  ← même position que la barre fermée
├──────────────────────────┤
│ 01  Philosophie           │  Fraunces 32, hauteur de ligne ≥ 56
│ 02  Méthode               │
│ 03  Accompagnements       │
│ 04  Orientation           │
│ 05  À propos              │
│ 06  Témoignages (si visible)
│ 07  Contact               │
│                           │
│ [   Faire le point   →  ] │  pleine largeur, hauteur 52
│                           │
│ 06 67 29 51 79            │  coordonnées issues de site.ts
│ astheoris.pro@gmail.com   │
└──────────────────────────┘

Les numéros « 01 »…« 07 » sont décoratifs (aria-hidden) et recalculés selon les liens visibles (si Témoignages est masqué, Contact porte « 06 »).

États visuels de la barre (attribut data-state sur <header>) :

État Condition Rendu
top Page d'accueil, scrollY ≤ 24 (ou ≤ 8 au retour, voir hystérésis) Fond transparent, pas de bordure, dégradé de lisibilité forest-950/70 → transparent sur toute la hauteur de la barre + 40 px, texte ivory-50
scrolled Page d'accueil, scrollY > 24 Fond forest-950 à 0,92 sous 768 px (sans flou) ; fond forest-950 à 0,85 avec backdrop-filter: blur(12px) à partir de 768 px ; bordure basse 1 px ivory-50/10 ; texte ivory-50
solid Pages légales, dès le rendu serveur ; jamais de retour à top Rendu identique à scrolled, sans dépendre du défilement

Hystérésis : le passage à scrolled se fait quand scrollY > 24 ; le retour à top quand scrollY ≤ 8. Cela évite le scintillement lors des rebonds de défilement sur iOS. Repli sans backdrop-filter : @supports not (backdrop-filter: blur(1px)) → fond forest-950 à 0,97 à partir de 768 px. L'en-tête reste sombre sur toute la page, y compris au-dessus des sections ivoire : il n'y a jamais de bascule claire/sombre du texte. Les valeurs de fond, de flou et de bordure sont celles de l'en-tête décrit en §5.4.3.

6.1.3 Source de contenu #

Fichier src/content/fr/navigation.ts, plus site.ts (nom de marque, coordonnées) et testimonials.ts (visibilité du lien Témoignages). Le schéma qui fait foi, clé par clé, est celui de navigation dans §9.3.3 ; le tableau ci-dessous indique l'usage de chaque clé.

Clé (§9.3.3) Usage dans cette section
skipLink Texte du lien d'évitement (« Aller au contenu »)
links[] (id, label, requires?) Un lien par entrée, dans l'ordre du fichier ; requires: 'publishableTestimonials' ne porte que le lien temoignages
cta (label, targetId) Bouton « Faire le point » vers contact
menu.openLabel, menu.closeLabel Noms accessibles du burger et du bouton de fermeture
menu.dialogLabel Nom accessible du panneau de menu (role="dialog")
menu.navLabel Nom accessible des deux <nav> (barre et panneau)
logo? (src, width, height) Logotype image facultatif ; absent : logotype texte

Valeurs par défaut (ordre imposé, identique à §4.2) : Philosophie, Méthode, Accompagnements, Orientation, À propos, Témoignages (requires: 'publishableTestimonials'), Contact. CTA : « Faire le point » vers contact. Le lien Témoignages est calculé côté serveur par getPublishableTestimonials() (fonction pure de src/lib/content/, voir §9.3 et §6.8) : une seule source de vérité pour la section, le lien de navigation et le lien du footer. Le type SectionAnchorId (union des identifiants de §4.2.1 : philosophie, methode, accompagnements, orientation, a-propos, temoignages, contact) est déduit de NAV_ITEM_IDS de §9.3.2.

Logotype : texte « Malka Debleds » (valeur de site.brandName) en Fraunces 22 px, graisse 500. Si logo est fourni, le fichier SVG déposé sous public/images/logo/ remplace le texte ; il est rendu par next/image avec unoptimized (un SVG n'est pas transformé), width/height explicites, alt = brandName. Un chemin contenant dev-fallback fait échouer le build de production (garde G-02 de §9.3.7).

6.1.4 Arborescence de composants et props #

Les noms de fichiers sont ceux de §12.2 (kebab-case) ; chaque composant porte le nom PascalCase de son export.

SkipLink                       (serveur) layout/skip-link.tsx — rendu avant tout
SiteHeader                     (serveur) layout/site-header.tsx — résout liens, visibilité Témoignages, coordonnées
└─ HeaderClient                ('use client') layout/header-client.tsx — état de défilement, section active, menu
   ├─ Wordmark                 (lien logotype)
   ├─ DesktopNav               (C) layout/desktop-nav.tsx — <nav> ; NavLink × n
   │  └─ NavLink               (ui/nav-link.tsx)
   ├─ CtaLink                  (ui/cta-link.tsx — bouton « Faire le point » de la barre)
   ├─ MobileMenuButton         (burger / croix)
   └─ MobileMenu               (C) layout/mobile-menu.tsx — portail dans #overlay-root ; AnimatePresence
      ├─ MobileNavList
      ├─ CtaLink variant="sheet"
      └─ ContactLines
Hooks (src/hooks/) : use-header-scroll-state.ts, use-active-section.ts, use-focus-trap.ts, use-scroll-lock.ts, use-media-query.ts
export interface SiteHeaderProps {
  variant: 'landing' | 'legal';
}

export interface ResolvedNavLink {
  id: SectionAnchorId;
  label: string;
  /** '#methode' sur la page d'accueil ; '/#methode' sur les pages légales. */
  href: string;
}

export interface HeaderClientProps {
  variant: 'landing' | 'legal';
  brandName: string;
  logo?: Content['navigation']['logo'];     // Content : type agrégé de §9.3.3
  links: ResolvedNavLink[];                // déjà filtrés (Témoignages)
  cta: { label: string; href: string };    // '#contact' ou '/#contact'
  menu: Content['navigation']['menu'];
  contact: { phoneDisplay: string; phoneE164: string; email: string };
}

export function useActiveSection(ids: readonly SectionAnchorId[], enabled: boolean): SectionAnchorId | null;
export function useHeaderScrollState(opts: { enabled: boolean }): 'top' | 'scrolled';
export function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean): void;
export function useScrollLock(locked: boolean): void;

Structure du document (dans src/app/layout.tsx, voir aussi §12) :

<body>
  <div id="app-root">
    <SkipLink />            {/* « Aller au contenu » → #contenu */}
    <SiteHeader variant="…" />
    <main id="contenu" tabIndex={-1}>…</main>
    <SiteFooter />
  </div>
  <div id="overlay-root" />   {/* portail du panneau de menu */}
</body>

Le menu mobile est rendu dans #overlay-root pour une raison technique : backdrop-filter sur l'en-tête crée un bloc conteneur qui casserait un élément position: fixed imbriqué. #app-root est le seul élément rendu inert pendant que le menu est ouvert.

Échelle de superposition : lien d'évitement --z-skip, panneau de menu --z-menu, en-tête --z-header (jetons de §5.7.4 ; aucune valeur numérique n'est écrite dans les composants). Les couches internes du hero et des autres sections sont isolées par isolation: isolate sur leur racine, donc ne dépassent jamais l'en-tête.

6.1.5 États #

État Comportement
Rendu serveur Barre en état top (landing) ou solid (legal), liens xl présents dans le HTML, aucun lien actif
Hydratation Lecture immédiate de window.scrollY dans un effet de montage pour poser scrolled si la page est restaurée en milieu de défilement ; au plus une image de décalage visuel
Toujours visible À toute position de défilement et dans les deux sens, la barre reste à top: 0 sans transformation
Lien actif aria-current="location" + texte champagne-400 + filet de 2 px sous le libellé ; les autres liens : ivory-100
Lien survolé / focus Filet qui se dessine de gauche à droite (transform: scaleX, origine gauche)
Menu ouvert aria-expanded="true", défilement du document verrouillé (html[data-scroll-locked]), #app-root inerte, focus piégé dans le panneau
Mouvement réduit Changement de fond en fondu de 0,15 s au plus, filets affichés sans transition, panneau de menu apparaissant instantanément
Mobile < 640 CTA retiré de la barre, présent dans le panneau
Sans JavaScript Voir ci-dessous

Repli sans JavaScript (@media (scripting: none)) : l'en-tête passe en position: absolute avec fond forest-950 plein ; le burger est masqué ; la liste de liens du DesktopNav (toujours présente dans le HTML) devient un display: flex; flex-wrap: wrap; gap: 8px 20px sous le logotype, à toutes les largeurs. Aucun lien n'est actif. Les ancres natives fonctionnent.

6.1.6 Interactions (souris, tactile, clavier) #

Détection de défilement. Un seul écouteur passif, coalescé par requestAnimationFrame, alimente useHeaderScrollState. Constantes :

const SCROLLED_ENTER_PX = 24;   // devient 'scrolled' au-delà
const SCROLLED_EXIT_PX = 8;     // redevient 'top' en deçà (hystérésis)

Le hook ne renvoie que top ou scrolled. L'en-tête n'est jamais masqué, quel que soit le sens ou la vitesse du défilement. Sur les pages légales (variant="legal"), le hook est inactif et l'état est solid.

Section active. useActiveSection observe, via un IntersectionObserver, les éléments #accueil et ceux des liens affichés (ceux qui existent dans le DOM). Paramètres :

new IntersectionObserver(callback, {
  rootMargin: '-40% 0px -55% 0px',   // ligne de lecture : bande de 5 % entre 40 % et 45 % de la hauteur de fenêtre
  threshold: 0,
});

Règles : le hook tient un ensemble des identifiants qui intersectent la bande. Si l'ensemble contient plusieurs identifiants (frontière entre deux sections), la section la plus basse dans l'ordre du DOM gagne ; si accueil est le seul présent, la valeur est null (le hero n'a pas de lien). Si l'ensemble est vide (espace entre sections, footer), la dernière valeur est conservée, sauf si la page est au bas de document : la valeur devient alors contact. Aucune mise à jour de l'URL n'est faite au changement de section active (pas de pollution de l'historique). Le hook est inactif (enabled=false) sur les pages légales.

Clic sur un lien d'ancre (≥ xl). Comportement natif du navigateur sur href="#id". html { scroll-behavior: smooth } n'est déclaré que dans @media (prefers-reduced-motion: no-preference). Le défilement s'arrête à scroll-margin-top (en-tête + 16 px). Après le clic, DesktopNav place le focus sur la racine de la section cible (tabIndex={-1}, focus({ preventScroll: true })) au requestAnimationFrame suivant : la lecture du lecteur d'écran reprend à la section atteinte.

Logotype. Sur la page d'accueil : lien #accueil (retour en haut). Sur les pages légales et système : lien /.

CTA « Faire le point ». Lien #contact (page d'accueil) ou /#contact (pages légales). Pas de WhatsApp direct : le visiteur arrive sur la section Contact qui propose les deux voies (voir §6.9).

Menu mobile (panneau plein écran).

  • Ouverture : clic/tap/Entrée/Espace sur le burger (aria-label = menu.openLabel, aria-controls="menu-mobile", aria-expanded passe à true). Le burger reste dans #app-root, devenu inerte ; le bouton de fermeture du panneau (aria-label = menu.closeLabel) prend le relais.
  • Panneau : role="dialog", aria-modal="true", aria-label = menu.dialogLabel, id="menu-mobile", plein écran (position: fixed; inset: 0; height: 100dvh), fond forest-950 opaque, overflow-y: auto, overscroll-behavior: contain, padding-bottom: max(32px, env(safe-area-inset-bottom)). La première ligne du panneau reproduit exactement la géométrie de la barre (logotype + bouton de fermeture 44 × 44) pour éviter tout saut visuel : le panneau a sa propre barre.
  • Animation : fondu et translation verticale de -12px à 0 en durée fast ; apparition et disparition instantanées en mouvement réduit.
  • Focus initial : le bouton de fermeture. Piège de focus : Tab sur le dernier élément focalisable renvoie au premier ; Maj+Tab sur le premier renvoie au dernier. Éléments focalisables : bouton de fermeture, liens, CTA, lien téléphone, lien courriel.
  • Verrouillage du défilement : useScrollLock pose l'attribut data-scroll-locked sur <html> ; la règle html[data-scroll-locked] { overflow: hidden; } est déclarée dans src/styles/globals.css. Un compteur de verrous empêche qu'un second verrou futur libère le premier. html { scrollbar-gutter: stable } est déclaré globalement : la largeur du contenu ne change donc pas à l'ouverture (aucune compensation manuelle). La page ne doit utiliser 100vw nulle part pour cette raison.
  • Fermeture : (a) Échap ; (b) bouton de fermeture ; (c) activation d'un lien ; (d) passage à ≥ xl pendant que le panneau est ouvert (écouteur matchMedia('(min-width: 1280px)')) ; (e) changement de pathname sur les pages légales. Un appui sur le fond ne ferme pas le panneau (il couvre tout l'écran, il n'y a pas de fond).
  • Retour du focus : après (a) et (b), le focus retourne au burger (triggerRef.current.focus()), exécuté après suppression de l'attribut inert de #app-root (à la fin de l'animation de sortie, onExitComplete). Après (c), le focus va à la racine de la section cible : voir ci-dessous. Après (d), le focus va au logotype.
  • Activation d'un lien dans le panneau : preventDefault(), fermeture du panneau, puis (après libération du verrou, au requestAnimationFrame suivant) target.scrollIntoView({ behavior }) avec behavior = 'auto' en mouvement réduit et 'smooth' sinon, history.pushState(null, '', '#id'), puis target.focus({ preventScroll: true }) (la racine de section est tabIndex={-1}). Sur les pages légales, le lien est un <Link href="/#id"> de Next.js : la navigation se fait par le routeur et le focus arrive sur la page d'accueil par le comportement standard.
  • Tactile : zone de lien de 56 px de haut au minimum, aucun survol, touch-action: manipulation sur les liens. Pas de fermeture par glissement.
  • Orientation paysage à faible hauteur (< 500 px) : la liste passe à une taille de 24 px et le panneau défile ; aucun élément n'est coupé.
  • Bouton Retour du navigateur : n'est pas intercepté ; le panneau n'ajoute pas d'entrée d'historique.

Clavier de la barre desktop. Tab parcourt : lien d'évitement → logotype → liens → CTA. Les flèches ne sont pas interceptées (c'est une liste de liens, pas un menu ARIA).

6.1.7 Accessibilité #

  • Lien d'évitement SkipLink : premier élément focalisable, visible uniquement au focus (haut gauche, couche --z-skip, ivory-50 sur forest-950, pill 44 px), texte navigation.skipLink (« Aller au contenu »), cible #contenu (le <main>, tabIndex={-1}).
  • Landmark : <header> contenant <nav aria-label="Navigation principale"> (valeur menu.navLabel). Le panneau mobile contient son propre <nav aria-label="Navigation principale"> (un seul landmark navigation est exposé à la fois, car #app-root est inerte pendant l'ouverture).
  • Liens actifs : aria-current="location" (jamais page). Sur les pages légales : aucun aria-current.
  • Nom accessible du logotype : texte visible « Malka Debleds » + texte masqué « , retour en haut de page » (page d'accueil) ou « , retour à l'accueil » (autres pages). Le nom accessible commence par le texte visible (WCAG 2.5.3).
  • Burger : <button type="button">, icônes Lucide Menu et X en aria-hidden="true", aria-label selon menu.openLabel/menu.closeLabel, aria-expanded, aria-controls="menu-mobile".
  • Contraste : texte ivory-50 / ivory-100 sur forest-950 ≥ 15:1 ; en état top, le dégradé forest-950/70 garantit au moins 4,5:1 contre le pixel le plus clair du paysage (champagne-200). Critère de test dans §15.
  • Indicateur non coloré : le lien actif est souligné (pas seulement plus clair).
  • Mode contraste forcé (forced-colors: active) : bordure basse de 1 px conservée ; le soulignement actif utilise currentColor.
  • Impression : @media print { header { display: none } }.
  • L'en-tête étant toujours visible, il ne masque jamais un élément focalisé : scroll-margin-top (en-tête + 16 px) sur chaque section d'ancre et scroll-padding-top équivalent garantissent qu'un élément qui reçoit le focus n'est pas recouvert par la barre (WCAG 2.4.11).

6.1.8 Mouvement #

Références dans §8 : bascule top → scrolled (fondu du fond, 0,25 s, courbe easeClarte), soulignement des liens (échelle horizontale, durée fast), ouverture du panneau (fondu et translation verticale de -12px à 0, durée fast), fermeture (fondu instant). Propriétés animées : transform et opacity uniquement, à l'exception du fond de la barre (background-color, sans effet sur la mise en page). Mouvement réduit : voir 6.1.5.

6.1.9 Événements de mesure #

cta_click avec { cta: 'nav_faire_le_point' } sur le CTA de la barre et sur le CTA du panneau (même identifiant). Aucun événement pour les liens d'ancre, le burger ou le logotype. Le suivi se déclenche à l'événement click, sans empêcher la navigation (voir §9.8 pour track() et pour le composant CtaLink).

6.1.10 Cas limites #

Cas Comportement
Aucun témoignage publiable Lien Témoignages absent de la barre, du panneau et de la numérotation ; l'observateur ne cherche pas #temoignages
Page chargée avec /#contact En-tête visible, état scrolled, lien Contact actif après le premier rappel de l'observateur
Page restaurée au milieu du défilement État scrolled posé au montage
Zoom 200 % / 400 % À 320 CSS px de large, la barre reste sur une ligne (logotype + burger) ; le panneau défile
Texte agrandi par l'utilisateur Hauteurs en min-height ; pas de coupure du panneau
Rebond iOS en haut / en bas de page L'hystérésis et la règle de bas de document évitent le scintillement
Défilement par ancre vers une section lointaine L'en-tête reste en place pendant le défilement lissé
Navigateur sans backdrop-filter Repli forest-950 à 0,97 (à partir de 768 px)
Menu ouvert puis rotation de l'écran Panneau recalculé (100dvh) ; focus conservé
Deux CTA vers #contact (CTA et lien Contact) Seul le lien « Contact » porte aria-current ; le CTA n'est jamais « actif »
logo fourni mais introuvable au runtime Le build de production échoue (garde de ressources) ; en développement, repli sur le logotype texte avec avertissement console
Hash inconnu (/#xyz) Aucune section active, aucun message

6.1.11 Critères d'acceptation #

ID Critère
AC-6.1-01 À scrollY = 0 sur /, l'en-tête a un fond transparent et un backdrop-filter nul ; à scrollY = 25 il passe à scrolled : à 1280 px de large, fond calculé forest-950 à 0,85 et flou de 12 px ; à 390 px, fond forest-950 à 0,92 sans flou ; à scrollY = 9, après être passé à 25, il reste scrolled ; à scrollY = 8 il redevient transparent.
AC-6.1-02 L'en-tête est visible à toute position de défilement : après un défilement vers le bas de 600 px, puis vers le haut de 20 px, puis vers le bas de 100 px, son rectangle englobant garde top = 0 et sa propriété transform calculée vaut none.
AC-6.1-03 Aucune transition de transform n'est déclarée sur <header> ; la bascule top/scrolled n'anime que le fond.
AC-6.1-04 Avec prefers-reduced-motion: reduce, le fond change en 0,15 s au plus et le panneau de menu apparaît et disparaît instantanément.
AC-6.1-05 Un clic sur un lien d'ancre (≥ 1280 px) fait défiler jusqu'à la section sous l'en-tête (marge de 16 px) et place le focus sur la racine de cette section.
AC-6.1-06 Une tabulation clavier vers un lien de l'en-tête, après un défilement de 2 000 px, le garde visible et focalisé ; l'élément focalisé n'est jamais recouvert par la barre (scroll-margin-top).
AC-6.1-07 En défilant lentement du haut au bas de la page, aria-current="location" est posé successivement sur Philosophie, Méthode, Accompagnements, Orientation, À propos, (Témoignages si visible), Contact, avec au plus un lien porteur à la fois ; il est absent tant que #accueil est dans la bande de lecture.
AC-6.1-08 Au bas de la page (footer visible), le lien Contact est actif.
AC-6.1-09 rootMargin de l'observateur vaut exactement -40% 0px -55% 0px.
AC-6.1-10 À 1280 px, les sept liens, le logotype et le CTA tiennent sur une seule ligne sans débordement ni retour à la ligne ; à 1279 px, les liens sont remplacés par le burger.
AC-6.1-11 À 639 px, le CTA n'est pas dans la barre ; à 640 px il l'est, hauteur de 44 px.
AC-6.1-12 L'ouverture du menu : aria-expanded="true", le panneau a role="dialog", aria-modal="true" et id="menu-mobile", le focus est sur le bouton de fermeture, <html> porte data-scroll-locked et a overflow: hidden, #app-root porte inert.
AC-6.1-13 Tab sur le dernier élément focalisable du panneau renvoie au bouton de fermeture ; Maj+Tab sur le bouton de fermeture renvoie au dernier élément focalisable.
AC-6.1-14 Échap ferme le panneau ; le focus revient sur le burger ; inert et data-scroll-locked sont retirés. Un clic à l'extérieur d'un élément du panneau ne le ferme pas.
AC-6.1-15 L'activation d'un lien du panneau le ferme, fait défiler jusqu'à la section cible (sous l'en-tête, marge de 16 px) et place le focus sur la racine de cette section.
AC-6.1-16 Passer de 1000 à 1300 px de large menu ouvert ferme le panneau et supprime le verrou de défilement.
AC-6.1-17 Chaque cible tactile de la barre et du panneau mesure au moins 44 × 44 px (liens du panneau : hauteur ≥ 56 px).
AC-6.1-18 Aucun décalage de largeur du contenu de la page à l'ouverture du menu sur un navigateur avec barre de défilement classique (scrollbar-gutter: stable).
AC-6.1-19 Quand aucun témoignage n'est publiable, le DOM ne contient aucun lien vers #temoignages dans l'en-tête ni dans le panneau et la numérotation du panneau est contiguë.
AC-6.1-20 Sur /mentions-legales, l'en-tête est solid dès le chargement, tous les liens ont des href de la forme /#id, aucun lien n'a aria-current, et le logotype pointe vers /.
AC-6.1-21 Sur la page d'accueil, le CTA de la barre pointe vers #contact et émet cta_click avec cta = 'nav_faire_le_point' quand l'analytique est activée ; aucun appel quand elle est désactivée.
AC-6.1-22 Rapport de contraste du texte de la barre en état top ≥ 4,5:1 mesuré contre un fond champagne-200 sous le dégradé ; en état scrolled ≥ 4,5:1.
AC-6.1-23 Avec JavaScript désactivé : l'en-tête est visible en haut de page sous forme de liste de liens qui passe à la ligne, les ancres fonctionnent, aucun burger.
AC-6.1-24 Le lien d'évitement est le premier élément focalisable, devient visible au focus et déplace le focus vers #contenu.
AC-6.1-25 Aucun axe de violation (@axe-core/playwright) sur la page avec le panneau ouvert et fermé.

6.2 Hero « CLARTÉ » (B) #

6.2.1 Objectif et rôle dans le parcours #

Le hero est la section la plus importante du site : il doit, en moins de trois secondes, donner une impression de calme, de profondeur et de justesse, dire ce que Malka Debleds propose, et offrir deux sorties claires (prendre rendez-vous, découvrir la méthode). Composition imposée sur ordinateur : paysage panoramique de montagnes au coucher du soleil (vert forêt profond à gauche, lumière champagne à droite), portrait réel de Malka à droite du titre, mot « CLARTÉ » géant et semi-transparent derrière elle, titre monumental à gauche, progression verticale de la méthode à l'extrême droite, transition douce vers la section suivante en bas. Sous 1024 px, le texte vient d'abord et le paysage, le mot et le portrait sont réunis dans une bande cadrée sous les boutons : aucun texte n'est posé sur une image.

Le hero est la racine <section id="accueil" aria-labelledby="accueil-titre">, premier enfant de <main>. Il contient l'unique <h1 id="accueil-titre">. Il n'a pas de lien dans la navigation (le logotype y mène).

6.2.2 Disposition par breakpoint #

Règles de hauteur #
Largeur Règle
≥ 1024 px min-height: clamp(720px, 100svh, 1080px) ; la hauteur n'est jamais fixée, le contenu peut l'agrandir (zoom, texte agrandi). Réserve d'en-tête : padding-top: var(--header-h) plus 32 px. Réserve basse : la hauteur du fondu de transition (ci-dessous).
< 1024 px Aucune hauteur minimale : le hero a la hauteur de son contenu. Mêmes réserves d'en-tête (--header-h plus 32 px) et basse (hauteur du fondu).

Fondu de transition (bord bas) : height: clamp(96px, 14svh, 160px) à toutes les largeurs.

Le hero a position: relative; overflow: clip; isolation: isolate. Fond de la racine : à partir de 1024 px, le dégradé de repli linear-gradient(100deg, forest-950 0%, forest-800 38%, champagne-200 100%) plus un halo radial champagne-400/35 à 78 % / 40 % (peint pendant le chargement des images ; aucune rupture de couleur au chargement) ; sous 1024 px, linear-gradient(180deg, forest-950, forest-900) uni. Il n'utilise jamais 100vw ni 100vh (utiliser 100% et svh).

Desktop ≥ 1024 px — deux colonnes 7fr / 5fr #

Boîte de contenu de 1280 px maximum, centrée, avec des gouttières de 64 px de chaque côté en plus (voir §5.6.2). La grille du contenu est grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); column-gap: 24px. Les couches de paysage sont pleine largeur (hors conteneur) ; le texte et le portrait sont dans la grille ; le rail de méthode est positionné hors de la grille.

Largeur de fenêtre Colonne de texte Colonne du portrait (= largeur du portrait)
1024 px 509 px 363 px
1280 px 658 px 470 px
1440 px et plus 733 px 523 px
Élément Placement Remarques
Bloc texte (eyebrow, titre, description, CTA, chiffres de confiance, et sous 1280 px le rail horizontal) Colonne 1 (7fr), align-self: center dans l'espace entre l'en-tête et la réserve basse Le titre ne dépasse jamais la colonne ; règle de non-chevauchement en 6.2.11 : le bord droit du bloc du titre plus 24 px est inférieur ou égal au bord gauche du portrait
Portrait Colonne 2 (5fr), align-self: end Boîte au ratio 4:5 ; pied masqué par la crête proche. Les couches de média partagent exactement la même grille que le contenu (deux grilles jumelles de gabarit identique : l'une porte les couches de média sous les jetons --z-hero-word à --z-hero-portrait, l'autre le contenu à --z-hero-content)
Rail de méthode (≥ 1280 px) Positionné en absolu par rapport à la grille : top: 50%; right: -32px; transform: translateY(-50%) Aligné sur le bord droit de la boîte de contenu (jamais sur le bord de la fenêtre), avec un débord de 32 px dans la gouttière ; détail dans « Rail de méthode »
Mot « CLARTÉ » Hors grille, centré sur le hero Haut du mot à 14 % de la hauteur du hero ; détail dans « Couches »

Wireframe 1440 × 900 (échelle indicative) :

┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ [en-tête transparent 72]                                                                 │
│                          ░░░░░░░░ CLARTÉ (centré sur le hero, 14 %) ░░░░░░░░             │
│ ── Accompagnement vibratoire et stratégique…                  ╭───────────╮              │
│                                                               │ portrait  │   ┌────────┐ │
│ Retrouver la clarté                                           │           │   │Écouter ●│ │
│ pour faire grandir                                            │    4:5    │   │Explorer●│ │
│ une entreprise alignée.                                       │ 523 × 654 │   │Clarifier●│ │
│                                                               │           │   │Décider ●│ │
│ Une écoute profonde, une lecture claire…                      │           │   └────────┘ │
│                                                               │           │              │
│ [Prendre rendez-vous →] [Découvrir la méthode ↓]              │           │              │
│ 300+ personnes accompagnées │ 50+ personnes formées           ╰───────────╯              │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ crête proche ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ░░░░░░░░░ transition en dégradé vers l'ivoire (clamp(96px, 14svh, 160px)) ░░░░░░░░░░░░░░ │
└──────────────────────────────────────────────────────────────────────────────────────────┘
 Entre 1024 et 1279 px : pas de rail vertical ; rail horizontal de quatre pas sous les chiffres.
Tablette 768–1023 px et mobile < 768 px — texte d'abord, bande cadrée ensuite #

Une seule colonne centrée, de 688 px au plus à partir de 768 px (gouttières 40 px) ; en dessous de 768 px, le texte occupe toute la largeur moins des gouttières de 24 px. Ordre vertical, identique aux deux largeurs (c'est aussi l'ordre du DOM) :

  1. Eyebrow, puis titre (<h1>), puis description, puis groupe de CTA : texte posé sur le fond forêt uni du hero, jamais sur une image. Le titre est en display-xl (clamp(2.25rem, 7vw, 3.25rem)) ; une ligne peut passer sur deux lignes visuelles (pas de nowrap). Sous 480 px, les boutons sont empilés en pleine largeur.
  2. Bande cadrée : boîte de rayon 20 px, overflow: clip, isolation: isolate, sans marge négative ni chevauchement avec le texte. Ratio 16:10 de 768 à 1023 px, 9:10 en dessous de 768 px. Elle contient, de bas en haut : l'image de paysage aplati hero-mobile (object-fit: cover, object-position: 62% 50%), le mot « CLARTÉ » (clamp(4rem, 20vw, 7.5rem), centré horizontalement, haut du mot à 14 % de la bande), le halo, puis le portrait (hauteur 92 % de la bande, ratio 4:5, ancré en bas, right: 6%). Aucun voile de lisibilité n'est nécessaire (aucun texte dans la bande).
  3. Chiffres de confiance, sur deux colonnes de largeur égale.
  4. Rail horizontal de quatre pas (voir « Rail de méthode »).
  5. Réserve basse égale à la hauteur du fondu de transition.

Le texte est lisible et les CTA atteignables dès le premier écran à 390 × 844 : le premier écran montre l'eyebrow, le titre, la description et les deux boutons.

Wireframe 375 × 667 (le hero se prolonge sous le pli) :

┌───────────────────────┐
│ [en-tête 64, ☰]       │
│ ── eyebrow            │
│ Retrouver la clarté   │
│ pour faire grandir    │
│ une entreprise alignée│
│ Description…          │
│ [ Prendre rendez-vous]│  ← 52 px, pleine largeur
│ [ Découvrir la méthode]│
│ ╭───────────────────╮ │  ← bande cadrée 9:10, rayon 20
│ │    ░ CLARTÉ ░     │ │
│ │          ╭──────╮ │ │
│ │          │portr.│ │ │  ← 92 % de la bande, ancré bas
│ ╰──────────╰──────╯─╯ │
│ 300+          50+     │
│ accompagnées  formées │
│ ●──●──●──●            │  ← rail horizontal
│ Écouter … Décider     │
│ ░░░░ transition ░░░░  │  ← clamp(96px, 14svh, 160px)
└───────────────────────┘
Couches (de bas en haut) #

L'ordre des superpositions est porté par les jetons nommés de §5.7.4 ; aucune valeur numérique de z-index n'est écrite dans les composants. Le hero est isolé (isolation: isolate) : ces jetons sont locaux au hero.

Jeton Couche Largeurs Nature Position et taille
--z-hero-far hero-far : ciel et crêtes lointaines ≥ 1024 Image 2:1 opaque (next/image, fill, object-fit: cover, object-position: 60% 40%) top: -120px; left: 0; right: 0; height: calc(100% + 120px) (marge pour le parallaxe : chaque calque de parallaxe porte sa propre valeur de --parallax-extend, soit 120 px pour hero-far, 64 px pour hero-mid et 48 px pour le mot CLARTÉ). Seule image prioritaire de la page
--z-hero-mid hero-mid : vallée et brume ≥ 1024 Image 2:1 avec alpha top: -64px; left: 0; right: 0; height: calc(100% + 64px)
--z-hero-scrim Voile de lisibilité ≥ 1024 Dégradé CSS --scrim-hero-x (défini en §5.4.1) inset: 0. Situé sous le mot, le halo et le portrait : le visage n'est jamais voilé. Aucun voile n'existe sous 1024 px
--z-hero-word clarte-word : mot géant ≥ 1024, et dans la bande Texte décoratif Voir ci-dessous
--z-hero-halo portrait-halo : halo derrière le portrait ≥ 1024, et dans la bande Dégradé radial CSS champagne-200/45 → transparent Boîte du portrait agrandie de 120 %, centrée sur lui
--z-hero-portrait portrait : portrait de Malka ≥ 1024, et dans la bande Découpe 4:5 avec alpha (hero-portrait) Voir ci-dessous
--z-hero-near hero-near : crête proche ≥ 1024 Image 2:1 avec alpha, crête dessinée en bas du cadre inset: 0; object-fit: cover; object-position: 50% 100% ; recouvre le pied du portrait
--z-hero-veil Voile d'entrée ≥ 1024 Aplat forest-950 dont l'opacité passe de 1 à 0 pendant la séquence d'entrée (§8) inset: 0, pointer-events: none. Style par défaut, hors animation : opacity: 0; visibility: hidden
--z-hero-grain Grain ≥ 1024 Texture public/textures/grain.svg (§5.4.7) inset: 0, opacité --grain-opacity
--z-hero-content Bloc texte, rail, chiffres, badge de développement toutes Contenu Éléments interactifs ; tout ce qui est cliquable est au niveau de ce jeton ou au-dessus
--z-hero-edge transition : fondu vers la section suivante toutes linear-gradient(180deg, transparent, ivory-50) left: 0; right: 0; bottom: -1px; height: clamp(96px, 14svh, 160px) ; pointer-events: none

Toutes les couches décoratives portent pointer-events: none et aria-hidden="true", sauf le portrait (image de contenu, voir 6.2.7). Les couches hero-far, hero-mid et hero-near sont issues d'une même scène : leurs cadres 2:1 se superposent exactement, de sorte que le même object-fit et la même boîte les alignent.

Mot « CLARTÉ ». Texte tiré de hero.bigWord, police d'affichage Fraunces, graisse 600, line-height: 0.8, letter-spacing: 0.02em, white-space: nowrap, user-select: none, aria-hidden="true". Couleur : aplat champagne-200 à 0,14 d'opacité (constante WORD_OPACITY.hero de §8.3.1), sans dégradé ni masque. Taille : clamp(14rem, 21vw, 22rem) à partir de 1024 px ; clamp(4rem, 20vw, 7.5rem) dans la bande. Position desktop : centré sur le hero (left: 50%; transform: translateX(-50%); top: 14%). Il passe derrière le portrait : la partie droite du mot est masquée par Malka, ce qui crée la profondeur recherchée. Le texte du titre doit rester lisible là où il croise le mot (voir contraste en 6.2.7). Dans la bande, le mot est centré horizontalement et son haut est à 14 % de la hauteur de la bande.

Portrait. Boîte aspect-ratio: 4 / 5. Desktop : colonne 2 de la grille (largeur 363 px à 1024, 470 px à 1280, 523 px à 1440 et au-delà). Dans la bande (< 1024 px) : hauteur 92 % de la bande, largeur déduite du ratio, ancré en bas, right: 6%. Pied estompé par la classe utilitaire mask-fade-bottom de src/styles/globals.css (masque en dégradé vertical, opaque jusqu'à 88 % de la hauteur puis transparent à 100 %). Le portrait n'est jamais recadré par overflow du conteneur : il est entier dans sa boîte.

Parallaxe (desktop ≥ 1024 px, pointeur fin, sans mouvement réduit). Le moteur, ses bornes, ses conditions de désactivation et les valeurs font foi dans §8.3.1 ; ce tableau en rappelle la répartition par couche, en pixels de translation verticale sur la durée du défilement du hero, sous les noms de préréglages heroFar, heroWord, heroMid, heroPortrait, heroNear et heroText :

Couche Préréglage Translation Y maximale
hero-far heroFar 120 px au plus
clarte-word heroWord 48 px
hero-mid heroMid 64 px
portrait heroPortrait 24 px
hero-near heroNear 0 px (suit la page)
Bloc texte heroText 0 px : le texte ne bouge ni ne s'estompe jamais

Il n'y a aucun parallaxe lié au pointeur : le seul mouvement lié à l'utilisateur est le défilement. Le parallaxe est désactivé sous 1024 px, avec (pointer: coarse) et sous prefers-reduced-motion: reduce (couches alors statiques à leur position de repos). Les composants rendent toujours les mêmes éléments animés (m.div) quel que soit le profil de mouvement : la désactivation maintient simplement leurs valeurs de mouvement à 0, sans remontage de l'arbre quand le profil change après l'hydratation.

Titre — coupures de lignes contrôlées #

Le titre est « Retrouver la clarté pour faire grandir une entreprise alignée. » en trois lignes, coupées par la donnée, pas par la largeur du conteneur :

Ligne Texte Caractères
1 Retrouver la clarté 19
2 pour faire grandir 18
3 une entreprise alignée. 22

Rendu : trois <span> en bloc, séparés par une espace réelle ({' '}) pour que le nom accessible du <h1> soit la phrase continue. La révélation ligne par ligne est assurée par RevealLines (§8).

<h1 id="accueil-titre" className="font-display">
  {lines.map((line, i) => (
    <Fragment key={i}>
      <span className="block overflow-hidden pb-[0.12em] lg:whitespace-nowrap">
        <span className="block" data-hero-line>{line}</span>
      </span>
      {i < lines.length - 1 ? ' ' : null}
    </Fragment>
  ))}
</h1>

Typographie : Fraunces, graisse 300–400 selon §5, line-height: 1.05, letter-spacing: -0.015em, text-wrap: balance. Taille : jeton display-xl de §5.5.2, soit clamp(2.25rem, 4.4vw, 3.75rem) à partir de 1024 px (≈ 45 px à 1024, 56 px à 1280, 60 px à partir de 1364) et clamp(2.25rem, 7vw, 3.25rem) en dessous. À partir de 1024 px, whitespace-nowrap garantit exactement trois lignes ; la plus longue (22 caractères) mesure ≈ 495 px à 1024, 591 px à 1280 et 634 px à 1440 et plus, donc tient dans la colonne de texte (509, 658 et 733 px). Sous 1024 px, une ligne peut passer sur deux lignes visuelles ; chaque <span> reste un masque de révélation (voir §8). Le padding-bottom de 0,12 em dans le masque évite de rogner les jambages. Ce jeton est le seul réglage de taille : il n'existe pas de valeur de repli à ajuster au cas par cas.

Hiérarchie du bloc texte (de haut en bas, DOM = ordre de lecture) : eyebrow → titre → description → groupe de CTA → [bande cadrée, sous 1024 px uniquement] → chiffres de confiance → rail de méthode.

Élément Spécification
Eyebrow Manrope 12/13 px, majuscules, letter-spacing: 0.14em, champagne-400, filet horizontal de 32 px avant le texte. Max 2 lignes.
Description Manrope 18 px (20 px à partir de xl), line-height: 1.6, ivory-100, largeur max 34 rem.
CTA primaire Pilule 52 px, fond champagne-400, texte forest-950, icône Lucide ArrowRight 18 px ; survol : fond champagne-200, l'icône glisse de 3 px (transform).
CTA secondaire Pilule 52 px, bordure 1 px ivory-50/45, texte ivory-50, icône Lucide ArrowDown 18 px ; survol : bordure ivory-50, fond ivory-50/8.
Groupe de CTA display: flex; flex-wrap: wrap; gap: 12px. Sous 480 px : colonne, chaque bouton à 100 % de largeur. Écart avec la description : 32 px.
Chiffres de confiance Voir ci-dessous. Écart avec les CTA (ou avec la bande sous 1024 px) : 40 px.

Chiffres de confiance. Liste de deux éléments issus de site.trustFigures (aujourd'hui : 300+ « personnes accompagnées », 50+ « personnes formées »). Chaque élément : valeur en Fraunces 40 px (champagne-200, tabular-nums), libellé Manrope 14 px (ivory-100) ; séparateur vertical de 1 px ivory-50/25 entre les éléments. Le comportement de comptage à l'apparition est défini dans §8 ; contraintes de ce hero : le HTML serveur contient la valeur finale (« 300+ ») ; la largeur du nombre est réservée (min-width en ch calculé sur la valeur finale) pour éviter tout décalage ; le nombre animé est aria-hidden="true" et une version complète « 300+ personnes accompagnées » (ariaLabel de la donnée) est présente en texte masqué visuellement pour les lecteurs d'écran, ce qui évite toute annonce d'une valeur intermédiaire. Sur mobile, les deux chiffres sont sur deux colonnes de largeur égale ; le libellé peut passer sur deux lignes. Aucun chiffre n'est rendu avec une valeur ≤ 0 ; si trustFigures est vide, le bloc entier est omis (aucun espace résiduel).

Rail de méthode. <nav aria-label="…"> (valeur hero.railLabel) contenant une <ol> de quatre liens issus de method.ts (ordre et libellés), chacun vers l'ancre #methode-<id> (voir 6.4 pour le comportement du compas).

  • ≥ 1280 px : vertical, positionné dans la gouttière droite (voir le tableau de la grille). Un filet vertical de 1 px ivory-50/25 relie quatre repères (cercle de 10 px). Chaque entrée : une capsule (pilule) de fond forest-950 à 0,80 contenant le numéro « 01 » (Manrope 11 px, champagne-400, aria-hidden) puis le libellé (Manrope 13/14 px, ivory-50, horizontal), à gauche du repère, aligné à droite ; padding de la capsule 4 × 10 px. Hauteur d'entrée 56 px, soit 224 px au total. Les capsules garantissent le contraste du libellé quel que soit le paysage derrière. Au survol ou au focus d'une entrée, le filet se remplit en champagne-400 du premier repère jusqu'à cette entrée (transform: scaleY, origine haut) et le libellé reste ivory-50 avec un soulignement de 1 px.
  • 1024–1279 px : horizontal, dans le flux, sous les chiffres de confiance, dans la colonne de texte (qui bénéficie du voile --scrim-hero-x). Mêmes règles que ci-dessous, sans capsule.
  • < 1024 px : horizontal, dans le flux, après les chiffres de confiance (écart 32 px). Grille de 4 colonnes égales ; chaque colonne : repère (10 px) relié au suivant par un filet horizontal, libellé 13 px en dessous, hauteur de cible 56 px.
  • Les liens ont un nom accessible égal au libellé visible (« Écouter »). Le numéro n'est pas lu. Le nom accessible du groupe est « Les quatre temps de la méthode ».

Transition vers la section suivante. La section Philosophie (6.3) commence en ivory-50 ; le dernier pixel du hero est ivory-50 exact (couche --z-hero-edge). Le texte et le rail sont toujours au-dessus de la zone de transition (la réserve basse la dépasse). Aucune ligne de séparation ne doit apparaître entre les deux sections (décalage de -1 px sur la couche).

6.2.3 Source de contenu #

Fichier src/content/fr/hero.ts, plus site.ts (trustFigures) et method.ts (rail). Le schéma qui fait foi, clé par clé, est celui de hero dans §9.3.3 ; le tableau ci-dessous indique l'usage de chaque clé et sa valeur par défaut (la formulation éditoriale de référence est dans §7).

Clé (§9.3.3) Usage Valeur par défaut
eyebrow Texte au-dessus du titre « Accompagnement vibratoire et stratégique des entrepreneurs »
headlineLines Les trois lignes du <h1> ['Retrouver la clarté', 'pour faire grandir', 'une entreprise alignée.']
description Paragraphe sous le titre « Une écoute profonde, une lecture claire et des actions concrètes pour avancer avec plus de sens, de cohérence et de justesse. »
primaryCta (label, targetId) Bouton principal vers contact « Prendre rendez-vous »
secondaryCta (label, targetId) Bouton secondaire vers methode « Découvrir la méthode »
bigWord Mot géant décoratif « CLARTÉ »
railLabel Nom accessible du rail « Les quatre temps de la méthode »
images Un emplacement par fichier du gabarit ci-dessous (hero-far, hero-mid, hero-near, hero-mobile, hero-portrait) Portrait : alt « Portrait de Malka Debleds » ; toutes les autres images : alt vide (couches décoratives)

Les bornes de longueur (trois lignes exactement, longueur maximale de chaque ligne, longueur de la description, bigWord en majuscules, alt non vide pour le portrait) sont celles du schéma ; une valeur hors borne fait échouer le build.

Gabarit des ressources visuelles (emplacements à fournir par Malka). Malka dépose un seul fichier maître par emplacement sous public/images/hero/ ; next/image produit les largeurs et les formats (AVIF et WebP) à la demande. Il n'y a ni variante de largeur à fournir, ni étape de préparation, ni manifeste.

Emplacement Fichier Ratio Source minimale Alpha Affichage
hero-far public/images/hero/hero-far.webp 2:1 3840 × 1920 non (opaque) ≥ 1024 px ; seule image prioritaire
hero-mid public/images/hero/hero-mid.webp 2:1 3840 × 1920 oui ≥ 1024 px
hero-near public/images/hero/hero-near.webp 2:1 3840 × 1920 oui ≥ 1024 px
hero-mobile public/images/hero/hero-mobile.webp 16:10 (paysage) 1600 × 1000 non < 1024 px uniquement, dans la bande (object-fit: cover)
hero-portrait public/images/hero/hero-portrait.webp 4:5 1920 × 2400 oui (découpe) toutes largeurs

Les maîtres sont des WebP sans perte (les trois couches de paysage et le portrait avec canal alpha ; hero-far et hero-mobile opaques). Les qualités de transcodage autorisées sont 60 et 75 : les images de paysage utilisent 60, le portrait 75 (configuration de next/image en §11.3.3).

Règles de chargement : hero-far est la seule image priority (donc la seule pré-chargée) ; sa propriété sizes vaut (max-width: 1023px) 1px, 100vw, de sorte qu'en dessous de 1024 px le navigateur ne choisit que la plus petite largeur. Toutes les autres images du hero sont en loading="lazy" et placées dans des conteneurs display: none hors de leur plage de largeurs (hidden lg:block pour hero-mid, hero-near et le portrait de bureau ; lg:hidden pour la bande, son image hero-mobile et son portrait) : un navigateur ne télécharge pas une image paresseuse d'un conteneur display: none. Les sizes du portrait sont (min-width: 1024px) min(36vw, 523px), (min-width: 768px) 320px, 72vw.

Règles de provenance (rappel de §1) : portrait réel de Malka uniquement, jamais généré ni retouché par IA ; paysage sous licence couvrant l'usage web commercial ou création originale, avec une entrée dans le registre docs/assets-licences.json ; les couches hero-far, hero-mid et hero-near sont issues d'une même scène pour que la lumière soit cohérente (ombres vert forêt à gauche, lumière champagne à droite).

Gardes de build (script scripts/check-assets.ts, exécuté uniquement au build, jamais au démarrage du conteneur) : (1) chaque fichier attendu existe ; (2) ses dimensions respectent les minima du tableau (lues avec les métadonnées de sharp) ; (3) son ratio est égal au ratio de l'emplacement à ±1 % ; (4) les couches hero-mid, hero-near et hero-portrait ont un canal alpha ; (5) une entrée existe dans docs/assets-licences.json ; (6) en APP_ENV=production, aucun emplacement ne référence un chemin contenant dev-fallback. Un échec arrête le build avec la liste des fichiers en cause. En développement, les emplacements acceptent des SVG (public/images/dev-fallback/) : les contrôles de format, de taille et de ratio sont des gardes de production.

Le budget d'images au-dessus de la ligne de flottaison sur mobile (émulation Lighthouse mobile) est de 350 KB au maximum.

6.2.4 Arborescence de composants et props #

Les noms de fichiers sont ceux de §12.2 (kebab-case, sous src/components/sections/hero/ sauf mention contraire) ; chaque composant porte le nom PascalCase de son export.

HeroSection                       (S) hero-section.tsx
├─ HeroLandscape                  (C) hero-landscape.tsx — uniquement ≥ 1024 px (`hidden lg:block`) ; chaque couche dans un ParallaxLayer (§8)
│  ├─ HeroFarLayer                (S) hero-far-layer.tsx — <Image priority>, seule image prioritaire
│  ├─ couche hero-mid             (<Image> lazy)
│  └─ couche hero-near            (<Image> lazy)
├─ voile de lisibilité            (div, jeton --z-hero-scrim)
├─ HeroClarteWord                 (C) hero-clarte-word.tsx — variantes « hero » et « bande »
├─ HeroPortrait                   (C) hero-portrait.tsx — halo + portrait, variantes « bureau » et « bande »
├─ voile d'entrée et grain        (div, jetons --z-hero-veil et --z-hero-grain)
├─ HeroGrid                       (jeton --z-hero-content)
│  ├─ colonne de texte
│  │  ├─ HeroEyebrow
│  │  ├─ HeroTitle                (RevealLines, §8)
│  │  ├─ HeroDescription
│  │  ├─ HeroActions              (2 × CtaLink)
│  │  ├─ HeroBand                 (S) hero-band.tsx — < 1024 px (`lg:hidden`) : hero-mobile + HeroClarteWord + HeroPortrait
│  │  ├─ HeroTrustFigures         (C) hero-trust-figures.tsx — TrustFigure × n ; CountUp défini en §8
│  │  └─ HeroMethodRail           (S) hero-method-rail.tsx — <nav><ol> ; HeroMethodRailItem × 4
│  └─ DevFallbackBadge            (S) dev-fallback-badge.tsx — hors production
└─ fondu de transition            (div, jeton --z-hero-edge)

Les composants client (C) sont des îlots qui reçoivent leurs enfants serveur et leurs textes par props ; aucun n'importe getContent ni lib/booking/index.ts.

export interface HeroSectionProps {
  content: Content['hero'];                  // Content : type agrégé de §9.3.3
  figures: ReadonlyArray<{ id: string; value: number; suffix: string; label: string; ariaLabel?: string }>;
  steps: ReadonlyArray<{ id: MethodStepId; label: string }>;
  showDevBadge: boolean;                     // un emplacement référence 'dev-fallback' && APP_ENV !== 'production'
}

export interface HeroPortraitProps {
  variant: 'desktop' | 'band';
  slot: Content['hero']['images']['portrait'];
}

export interface HeroMethodRailProps {
  label: string;
  steps: HeroSectionProps['steps'];
}

Chaque <Image> du hero reçoit width et height explicites (dimensions de l'emplacement), alt de la donnée, quality 60 (paysage) ou 75 (portrait), et sizes selon les règles de chargement de 6.2.3. L'image de paysage de la couche hero-far porte data-lcp="hero-landscape" et celle de la bande data-lcp="hero-band". Un emplacement de type SVG (développement) est rendu par next/image avec unoptimized.

6.2.5 États #

État Comportement
Rendu serveur Tout le texte, les CTA, les chiffres (valeurs finales), le rail et les <img> sont dans le HTML. Aucun état initial d'animation n'est appliqué : un visiteur sans JavaScript voit la composition finale statique.
Hydratation Le garde d'hydratation de §8 pose les états initiaux (lignes du titre masquées, couches décalées, rail non tracé) puis joue la séquence d'entrée : paysage → mot CLARTÉ → portrait → lignes du titre → description → CTA → chiffres → rail. Durée totale ≤ 2,2 s (§8). Le titre commence à se révéler dans les 0,1 s qui suivent l'hydratation.
Images en cours de chargement Le dégradé de fond reste affiché. Le portrait se révèle via la séquence d'entrée, déclenchée quand l'image est complète (img.complete) ou après 1 200 ms au plus.
Image en erreur Une couche décorative en erreur est retirée (hidden), la composition continue avec les couches restantes. Si le portrait est en erreur : il est retiré, la boîte garde sa place (pas de décalage), un avertissement est écrit en console en développement ; le halo et CLARTÉ restent. Une erreur survenue avant l'hydratation est détectée dans un effet de montage (img.complete && img.naturalWidth === 0).
Repli de développement Si un emplacement référence dev-fallback, les SVG de public/images/dev-fallback/ (voir ci-dessous) sont servis ; DevFallbackBadge affiche « Visuel provisoire » en bas à gauche du hero (texte 12 px, fond forest-950/80, bordure champagne-400), aria-hidden, uniquement quand APP_ENV !== 'production'. En production, le build échoue avant d'arriver là (6.2.3).
Mouvement réduit Composition finale statique : aucune translation, aucun parallaxe, aucune dérive ambiante ; le mot CLARTÉ est à son opacité finale ; le rail est tracé ; les chiffres affichent la valeur finale ; les apparitions éventuelles sont des fondus ≤ 0,2 s ou immédiates.
Mobile Pas de filter/flou, pas de parallaxe, aucune boucle ambiante ; seules les entrées en fondu/translation courte (§8).
Boucles ambiantes (≥ 1024 px) Halos et brume : un seul cycle de 5 s au plus à chaque entrée du hero dans le viewport (animation-iteration-count: 1), qui revient à l'état de repos de départ (WCAG 2.2.2) ; en pause quand le hero n'est pas visible ou que l'onglet est masqué (§8).
Impression Les couches d'image et le mot CLARTÉ sont masqués ; le titre, la description et les coordonnées de contact (via le footer) restent ; fond blanc, texte ink-900.
Contraste forcé Couches décoratives masquées ; texte en CanvasText ; boutons avec bordure ButtonText.

Fichiers de repli de développement (public/images/dev-fallback/) : hero-far.svg, hero-mid.svg, hero-near.svg, hero-mobile.svg, hero-portrait.svg (le fichier about-portrait.svg sert à la section À propos). Les quatre premiers sont des dégradés de paysage stylisés (crêtes en polygones, mêmes teintes que la composition cible). Le portrait est une forme abstraite sans visage (tête ovale et épaules, teinte sage-400). Aucun de ces fichiers ne représente une personne réelle ou générée.

6.2.6 Interactions (souris, tactile, clavier) #

  • CTA primaire « Prendre rendez-vous » : lien <a href="#contact">. Souris : survol (6.2.2). Tactile : cible 52 px de haut, retour visuel au toucher (:active : scale(0.98)). Clavier : Entrée active le lien ; focus visible (anneau champagne-400).
  • CTA secondaire « Découvrir la méthode » : lien <a href="#methode">. Comportement identique.
  • Les deux CTA utilisent le défilement natif vers l'ancre (scroll-behavior: smooth hors mouvement réduit, scroll-margin-top de la section).
  • Rail : chaque entrée est un lien <a href="#methode-<id>">. Un clic fait défiler jusqu'à la zone du compas et active l'étape correspondante (le compas écoute hashchange, voir 6.4.6). Survol/focus : remplissage du filet jusqu'à l'entrée (≥ 1280 px). Tactile : pas de survol ; l'état :active colore le repère.
  • Ordre de tabulation : (lien d'évitement) → en-tête → CTA primaire → CTA secondaire → 4 entrées du rail. Les chiffres, le mot, le portrait et les couches ne sont pas focalisables.
  • Souris : aucune réaction du paysage au pointeur (pas de parallaxe pointeur) ; le seul mouvement lié à l'utilisateur est le défilement.
  • Clavier : aucun piège ; aucune interaction nécessitant la souris.

6.2.7 Accessibilité #

  • <h1> unique, nom accessible = phrase complète « Retrouver la clarté pour faire grandir une entreprise alignée. ».
  • <nav aria-label> du rail avec <ol> ; l'ordre de la liste suit la séquence de la méthode.
  • Portrait : alt « Portrait de Malka Debleds » (ni description physique, ni texte superflu) ; toutes les autres images : alt="". Le mot CLARTÉ : aria-hidden="true", absent de l'arbre d'accessibilité. Quand le portrait est rendu deux fois dans le DOM (version de bureau et version de la bande), une seule est exposée : l'autre est dans un conteneur display: none.
  • Contraste : à partir de 1024 px, le texte du bloc (titre, description, libellés) doit atteindre au moins 4,5:1 (7:1 visé pour la description) contre le pire fond composite, c'est-à-dire le voile --scrim-hero-x posé sur champagne-200 plus le mot à 0,14. Le voile reste à une opacité d'au moins 0,80 sur toute la largeur de la colonne de texte ; la vérification est un test automatisé (6.2.11). Les libellés du rail vertical reposent sur leurs capsules (forest-950 à 0,80). Sous 1024 px, le texte est posé sur le fond forêt uni (≥ 15:1).
  • Les boutons ont un texte visible explicite ; les icônes sont aria-hidden.
  • Les états interactifs ne dépendent pas de la couleur seule : états actif/survol des entrées du rail = remplissage du filet + soulignement du libellé.
  • Zoom 200 % et 400 % : le contenu se réorganise sans défilement horizontal ni perte ; aucun texte n'est positionné de manière absolue (le rail vertical est un élément secondaire, remplacé par le rail horizontal sous 1280 px).
  • Le contenu « chiffres » est restitué une seule fois aux lecteurs d'écran (texte complet masqué visuellement, nombre animé aria-hidden).

6.2.8 Mouvement #

Références dans §8 : séquence d'entrée du hero (≤ 2,2 s, sans bloquer le LCP), masques de révélation ligne par ligne pour le titre (décalage stagger de 0,08 s entre les trois lignes), apparition contrôlée du mot CLARTÉ (WORD_OPACITY.hero = 0,14 à la fin), parallaxe multicouche (répartition en 6.2.2), boucles ambiantes du halo et de la brume (un cycle ≤ 5 s par entrée dans le viewport, voir 6.2.5), comptage des chiffres, tracé du rail, micro-interactions des boutons (classes .motion-press et .motion-arrow, neutralisées en mouvement réduit). Propriétés animées : transform, opacity, clip-path ; flou (filter: blur) ≤ 8 px réservé au desktop ; jamais de width, height, top, left, margin. Le texte est peint dans le HTML serveur : l'animation ne le rend jamais « invisible avant hydratation » (voir §8 pour le mécanisme).

6.2.9 Événements de mesure #

Élément Événement
CTA primaire cta_click { cta: 'hero_rdv' }
CTA secondaire cta_click { cta: 'hero_methode' }
Rail aucun (l'ouverture d'étape est mesurée par le compas : method_step_open, voir 6.4.9)

6.2.10 Cas limites #

Cas Comportement
trustFigures vide Bloc des chiffres omis ; le rail suit directement les CTA (ou la bande sous 1024 px)
Une seule valeur de confiance Un élément, sans séparateur
Valeur de confiance à 4 chiffres (ex. 1 200+) Largeur réservée recalculée ; le libellé passe à la ligne sans déborder
Titre modifié trop long Refusé par le schéma au build
Description plus longue que la borne du schéma Refusée par le schéma au build
Écran large 1920 × 1080 Hero à 1080 px de haut ; la boîte de contenu reste à 1280 px ; les couches de paysage couvrent la largeur ; le mot CLARTÉ plafonne à 22 rem
Écran bas, par exemple 1366 × 600 Hauteur plancher de 720 px : le hero dépasse la fenêtre, tout reste lisible, les CTA sont accessibles par défilement
Écran 1024 × 768 Mise en page desktop avec portrait de 363 px de large ; le titre à ≈ 45 px tient sur trois lignes ; rail horizontal sous les chiffres
Smartphone en paysage (hauteur < 500 px) Mise en page « bande » (< 1024 px) ; le texte se lit en défilant
Redimensionnement à travers 1024 px Les conteneurs d'images actifs changent ; aucune animation n'est rejouée ; le parallaxe est activé/désactivé sans saut ni remontage
Portrait remplacé par un autre ratio Refusé par check-assets (ratio 4:5 à ±1 %)
Police d'affichage non chargée Repli ajusté de next/font (métriques corrigées) : pas de décalage de lignes
JavaScript désactivé Composition finale statique ; rail et CTA fonctionnent par ancres
Lien profond /#methode-clarifier Voir 6.4.6 ; le hero n'est pas concerné
Navigateur sans AVIF next/image sert le WebP
Réseau lent Dégradé de fond + texte immédiatement lisibles ; le texte n'attend jamais les images

6.2.11 Critères d'acceptation #

ID Critère
AC-6.2-01 Le HTML servi (sans exécution de JavaScript) contient le <h1> avec les trois lignes, la description, les deux CTA, les valeurs finales des chiffres, le rail à quatre liens et l'<img> du portrait.
AC-6.2-02 Le <h1> a le nom accessible exact « Retrouver la clarté pour faire grandir une entreprise alignée. ».
AC-6.2-03 À 1024, 1280, 1440 et 1920 px de large, chacun des trois <span> du titre occupe exactement une ligne (hauteur de la boîte = une line-height).
AC-6.2-04 À 1024, 1280, 1440 et 1920 px, le bord droit de la boîte du titre plus 24 px est inférieur ou égal au bord gauche de la boîte du portrait, le bord droit de chaque ligne de texte du titre est inférieur ou égal au bord droit de la colonne de texte, et la boîte du bloc texte ne chevauche pas la boîte du rail.
AC-6.2-05 Hauteur du hero : à 1440 × 900 = 900 px ; à 1920 × 1200 = 1080 px ; à 1366 × 600 = 720 px ; à 390 × 844 la hauteur est celle du contenu (aucune hauteur forcée).
AC-6.2-06 Pile de couches desktop : les valeurs de z-index calculées respectent l'ordre --z-hero-far < --z-hero-mid < --z-hero-scrim < --z-hero-word < --z-hero-halo < --z-hero-portrait < --z-hero-near < --z-hero-veil < --z-hero-grain < --z-hero-content < --z-hero-edge, et aucun z-index numérique littéral n'apparaît dans les fichiers du hero.
AC-6.2-07 Sous 1024 px, les images hero-mid, hero-near et le portrait de bureau ne sont pas téléchargés, et hero-far n'est demandé qu'à sa plus petite largeur ; à partir de 1024 px, hero-mobile et le portrait de la bande ne sont pas téléchargés (journal réseau Playwright).
AC-6.2-08 Sur mobile émulé (Lighthouse, 412 px, DPR 1,75), le poids total des images au-dessus de la ligne de flottaison est ≤ 350 KB.
AC-6.2-09 LCP ≤ 2,5 s en mobile émulé ; l'élément LCP est soit un élément portant data-lcp, soit le <h1> ; hero-far est la seule image pré-chargée avec fetchpriority="high".
AC-6.2-10 CLS ≤ 0,05 pendant la séquence d'entrée complète ; chaque <img> a width et height ; aucun contenu ne se déplace à l'arrivée des images ou des polices.
AC-6.2-11 L'animation d'entrée ne laisse jamais le titre à opacity: 0 dans le HTML initial ; la séquence se termine en ≤ 2,2 s.
AC-6.2-12 Avec prefers-reduced-motion: reduce, aucune translation ni parallaxe ni boucle ambiante n'est active ; la composition finale est visible immédiatement ou après un fondu ≤ 0,2 s.
AC-6.2-13 Le parallaxe est inactif sous 1024 px et avec (pointer: coarse) ; à 1440 px, la couche hero-far se translate d'au plus 120 px et hero-near de 0 px ; le bloc texte ne bouge ni ne s'estompe ; aucun mouvement ne répond au pointeur. Le passage du profil de mouvement après l'hydratation ne remonte aucun nœud (l'identité du nœud [data-lcp] est conservée).
AC-6.2-14 Contraste mesuré du texte (titre, description, libellés, chiffres) ≥ 4,5:1 contre le fond composite le plus clair, y compris sur les capsules du rail ; contraste des CTA ≥ 4,5:1 (texte) et ≥ 3:1 (bordure du CTA secondaire).
AC-6.2-15 Le mot CLARTÉ est aria-hidden, non sélectionnable, pointer-events: none, absent de l'arbre d'accessibilité, de couleur champagne-200 à l'opacité 0,14 (aplat, sans dégradé) ; sa taille calculée vaut clamp(14rem, 21vw, 22rem) à partir de 1024 px.
AC-6.2-16 L'alt du portrait vaut « Portrait de Malka Debleds » ; toutes les autres images du hero ont alt="".
AC-6.2-17 Le CTA primaire pointe vers #contact, le secondaire vers #methode ; ils émettent respectivement cta_click avec hero_rdv et hero_methode quand l'analytique est activée.
AC-6.2-18 Le rail contient quatre liens vers #methode-ecouter, #methode-explorer, #methode-clarifier, #methode-decider, dans cet ordre, avec un nom accessible égal au libellé de l'étape ; chaque cible tactile mesure ≥ 44 × 44 px.
AC-6.2-19 À 320 px de large : aucun défilement horizontal, boutons pleine largeur, titre lisible ; aucune boîte de texte ne coupe la boîte de la bande ni celle d'une image ; aucune marge négative n'est calculée sur les éléments du hero.
AC-6.2-20 À 768 px : texte, puis bande cadrée au ratio 16:10 (rayon 20 px), puis chiffres, puis rail horizontal ; à 390 px : même ordre, bande au ratio 9:10 ; à 1024 px : mise en page desktop avec grille 7fr / 5fr et rail horizontal sous les chiffres ; à 1280 px : rail vertical dans la gouttière droite.
AC-6.2-21 Avec trustFigures = [], le bloc des chiffres est absent du DOM et le rail n'est pas déplacé de plus de 40 px.
AC-6.2-22 Le dernier pixel du hero est ivory-50 ; aucune ligne de séparation visible avec la section Philosophie (comparaison de pixels sur les 4 dernières lignes à 1440 px).
AC-6.2-23 APP_ENV=production avec un emplacement référençant dev-fallback : pnpm build échoue avec le nom de l'emplacement ; en development, DevFallbackBadge est visible et aria-hidden.
AC-6.2-24 Une image de portrait qui renvoie une erreur 404 ne produit ni icône d'image cassée ni décalage de mise en page.
AC-6.2-25 Aucune violation axe sur le hero ; ordre de tabulation conforme à 6.2.6.

6.3 Philosophie — Entreprise vivante (C) #

6.3.1 Objectif et rôle dans le parcours #

Première respiration après le hero : une section éditoriale, claire, en grande typographie, qui pose l'idée centrale du site — une entreprise n'est pas séparée de la personne qui la porte — et la décline en trois principes courts. Elle justifie le ton de la suite (écoute, lecture, action) et prépare la méthode. Elle ne contient aucun bouton : son rôle est de faire comprendre, pas de convertir.

Fond ivory-50. Racine <section id="philosophie" aria-labelledby="philosophie-titre">. Premier fond clair de la page : la transition depuis le hero est portée par le hero (6.2.2, couche de transition).

6.3.2 Disposition par breakpoint #

Rythme vertical de section : jeton --spacing-section (§5.6.1). Largeur de conteneur et gouttières selon §5. La racine porte overflow-x: clip (jamais overflow: hidden, qui casserait le positionnement sticky).

Desktop ≥ 1024 px — asymétrique sur 12 colonnes #
Élément Colonnes Remarques
Étiquette de marge (numéro « 01 » + « Philosophie » + filet) 1 → 3 position: sticky; top: calc(var(--header-h) + 32px) ; confinée à la zone d'ouverture (conteneur PhilosophyOpening regroupant étiquette, titre, lead et body) : elle se décolle à la fin de cette zone et ne croise jamais l'exergue ni les principes
Titre <h2> (deux lignes : « Entreprise » / « vivante ») 4 → 12 clamp(3.5rem, 8vw, 7rem) ; la 2ᵉ ligne en italique Fraunces, couleur champagne-600 (texte large, voir conventions)
Texte d'ouverture (lead) 4 → 10 Fraunces 300, clamp(1.5rem, 2.4vw, 2rem), line-height: 1.35, ink-900
Texte courant (body, liste de paragraphes) 4 → 8 Manrope 18 px, line-height: 1.7, ink-600
Citation en exergue (pullQuote) 2 → 9 (déborde volontairement dans la marge gauche) Fraunces italique 300, clamp(2rem, 3.6vw, 3rem), line-height: 1.2, filet vertical de 2 px champagne-400 à gauche (décalage de 24 px) ; grand guillemet « décoratif de 200 px en champagne-200, derrière le texte
Principes (3) 4–6, 7–9, 10–12 Trois colonnes de 3 colonnes de grille ; décalage vertical en escalier : 0, 48 px, 96 px (effet « croissance » descendant, sans carte ni cadre)

Wireframe 1440 :

 col 1──3        col 4────────────────────────────────────12
 ┌───────────┐   Entreprise
 │ 01        │   vivante                      (italique, champagne-600)
 │ ── Philo- │
 │ sophie    │   Texte d'ouverture en Fraunces, grande taille,
 │ (sticky)  │   trois à quatre lignes sur cols 4 → 10.
 └───────────┘
                 Paragraphe courant, cols 4 → 8, une ou deux phrases.

   col 2───────────────────────────────9
   ┃ « Une entreprise n'est pas séparée
   ┃   de la personne qui la porte. »            (exergue, italique)

                 ─────────       ─────────       ─────────
                 01 Sens         02 Cohérence    03 Justesse
                 texte court     texte court     texte court
                                 (+48 px)        (+96 px)
Tablette 768–1023 px #

Colonne unique de 688 px max. Ordre : étiquette (non sticky), titre (clamp(3.5rem, 11vw, 5.5rem)), lead, body, exergue pleine largeur avec filet de 2 px à gauche et retrait de 24 px, puis les trois principes en trois colonnes égales (gap 32 px, sans décalage vertical).

Mobile < 768 px #

Colonne unique. Titre clamp(3rem, 14vw, 4rem). lead à 24 px. Exergue à 28 px avec filet à gauche. Principes empilés ; chacun en grille [2.5rem_1fr] : numéro à gauche, titre et texte à droite, filet supérieur de 1 px sage-400. Écart de 32 px entre principes.

6.3.3 Source de contenu #

Fichier src/content/fr/philosophy.ts. Le schéma qui fait foi, clé par clé, est celui de philosophy dans §9.3.3 ; le tableau indique l'usage de chaque clé.

Clé (§9.3.3) Usage dans cette section
eyebrow Libellé de l'étiquette de marge (« Philosophie »)
titleLines Deux lignes du <h2> : ['Entreprise', 'vivante']
lead Texte d'ouverture
body Paragraphes courants (liste ; zéro paragraphe : bloc omis)
pullQuote Exergue, sans attribution
principles Exactement trois entrées { id, title, text }, ids uniques

Valeurs fixes issues de la thèse du site, reprises à l'identique de §7.5 : titleLines = ['Entreprise', 'vivante'] ; pullQuote = « Une entreprise n'est pas séparée de la personne qui la porte. » ; les trois principes reprennent les trois qualités annoncées par le hero (« plus de sens, de cohérence et de justesse ») : ids sens, coherence, justesse, titres « Sens », « Cohérence », « Justesse ». Les textes lead, body et text de chaque principe sont rédigés dans §7 ; ce sont des propositions à valider par Malka (registre de validation du contenu, clé copyDeck : la garde G-05 de §9.3.7 bloque la mise en production tant qu'elle n'est pas validée). Aucun d'eux ne contient de promesse de résultat, de revendication de santé ou de chiffre.

L'exergue est la thèse de la section, non attribuée : ce n'est pas un témoignage, ce n'est pas une citation d'un tiers. Il ne reçoit donc ni <blockquote>, ni <cite>, ni nom d'auteur.

6.3.4 Arborescence de composants et props #

PhilosophySection                   (serveur) src/components/sections/philosophy/philosophy-section.tsx
├─ PhilosophyOpening                (serveur : grille 12 colonnes, borne du sticky)
│  ├─ SectionMarginLabel            (étiquette sticky ; serveur)
│  ├─ PhilosophyTitle               (titre ; révélation ligne par ligne par RevealLines, components/motion/reveal-lines.tsx)
│  ├─ PhilosophyLead                (serveur ; enveloppé par <Reveal>)
│  └─ PhilosophyBody                (serveur ; <Reveal>)
├─ PullQuote                        ('use client', pull-quote.tsx : guillemet en parallaxe légère + tracé du filet)
└─ PrincipleList                    (serveur, <ul>)
   └─ PrincipleItem × 3             (<li> ; <Reveal> avec décalage)
export interface PhilosophySectionProps { content: Content['philosophy'] }   // Content : type agrégé de §9.3.3

export interface PullQuoteProps { text: string }

export interface PrincipleItemProps {
  index: 1 | 2 | 3;            // détermine le numéro et le décalage vertical en escalier (desktop)
  principle: Content['philosophy']['principles'][number];
}

Reveal et RevealLines sont les composants d'apparition au défilement définis dans §8 (variantes nommées, déclenchement par useInView, une seule fois). PullQuote rend toujours les mêmes éléments animés (m.*) quel que soit le profil de mouvement ; la parallaxe désactivée garde simplement sa valeur de mouvement à 0, sans remontage.

6.3.5 États #

État Comportement
Rendu serveur Tout le texte est présent, à l'état final et lisible ; aucun contenu dépendant du JavaScript
Hydratation / entrée dans le viewport Les révélations de §8 jouent une fois par élément (pas de rejeu au défilement inverse)
Mouvement réduit Tout est visible immédiatement ou après un fondu ≤ 0,2 s ; guillemet en place, sans parallaxe ; filets déjà tracés
Mobile Pas de parallaxe du guillemet ; mêmes révélations en simple fondu + translation de 16 px au plus
body vide (0 paragraphe) Le bloc est omis ; l'écart entre lead et exergue reste de 64 px
Sans JavaScript Rendu final statique ; étiquette sticky fonctionne (CSS pur)

Aucun état de chargement, d'erreur, vide ou désactivé n'existe pour cette section (contenu statique validé au build).

6.3.6 Interactions (souris, tactile, clavier) #

La section n'a aucun élément interactif. Aucun survol n'est requis. Les principes ne sont pas cliquables, aucun faux bouton. L'étiquette sticky n'est pas un lien.

6.3.7 Accessibilité #

  • Hiérarchie : <h2 id="philosophie-titre"> (les deux lignes sont dans un seul <h2>, séparées par une espace réelle) ; chaque principe est un <h3>.
  • Les principes forment une <ul> (pas de sémantique d'ordre) ; les numéros « 01 » à « 03 » sont aria-hidden.
  • Exergue : paragraphe <p> avec class="pull-quote" ; guillemet décoratif aria-hidden. Il n'est pas dans un <blockquote>.
  • L'italique de la seconde ligne du titre est purement visuel : pas de <em> (classe utilitaire italic sur la police Fraunces italique chargée, §5.5.1).
  • Contrastes : ink-900 et ink-600 sur ivory-50 ≥ 7:1 ; champagne-600 n'est utilisé que pour la 2ᵉ ligne du titre (≥ 56 px) et les filets (éléments graphiques ≥ 3:1) ; le petit texte de l'étiquette (« Philosophie ») utilise forest-700.
  • L'étiquette sticky n'est pas un titre et ne duplique pas le <h2> pour les lecteurs d'écran (aria-hidden="true" si elle répète le mot ; le numéro est toujours aria-hidden).
  • Pas de texte dans une image ; pas de contenu transmis seulement par la couleur.
  • Zoom 400 % : pas de défilement horizontal ; le sticky est désactivé sous 1024 px.

6.3.8 Mouvement #

Références dans §8 : révélation ligne par ligne du titre, apparition en fondu et translation courte du lead et du body, tracé du filet de l'exergue puis apparition du texte, apparition décalée des trois principes (décalage stagger de cartes, 0,1 s) avec tracé de leur filet supérieur, parallaxe très légère du grand guillemet (amplitude de §8, desktop avec pointeur fin et sans mouvement réduit uniquement). Propriétés animées : transform, opacity, clip-path. Chaque élément se révèle une seule fois à son entrée dans le viewport.

6.3.9 Événements de mesure #

Aucun.

6.3.10 Cas limites #

Cas Comportement
Texte de principe plus long que la borne du schéma Refusé au build
Moins ou plus de trois principes Refusé au build (tuple de longueur 3)
Titre modifié avec une seule ligne Refusé au build (deux lignes imposées)
Mot long dans le titre à 320 px overflow-wrap: anywhere en dernier recours ; vérifié avec « Entreprise »
Exergue à la borne maximale du schéma, sur mobile Passe sur 5 lignes maximum à 28 px ; pas de coupure du guillemet
Sticky et zone d'ouverture courte Si la zone d'ouverture est plus courte que la hauteur de l'étiquette plus 32 px, l'étiquette reste à sa place naturelle (comportement sticky normal)
body à plusieurs paragraphes Écart de 20 px entre paragraphes

6.3.11 Critères d'acceptation #

ID Critère
AC-6.3-01 La section a id="philosophie", un seul <h2> dont le texte accessible est « Entreprise vivante », et trois <h3> (« Sens », « Cohérence », « Justesse »).
AC-6.3-02 À 1440 px, le titre commence à la colonne 4, l'exergue à la colonne 2, les principes sont en colonnes 4–6 / 7–9 / 10–12 avec des décalages verticaux de 0, 48 et 96 px (± 2 px).
AC-6.3-03 À 768 px et 390 px, aucune des colonnes décalées n'est appliquée ; les principes sont en trois colonnes (768) ou empilés (390) ; aucun défilement horizontal.
AC-6.3-04 À 1440 px, l'étiquette de marge reste collée sous l'en-tête pendant la lecture du titre, du lead et du body, puis se décolle ; sa boîte ne chevauche jamais celle de l'exergue ; sous 1024 px elle n'est pas sticky.
AC-6.3-05 L'exergue n'est pas dans un <blockquote> et n'a ni <cite> ni attribution.
AC-6.3-06 Toutes les chaînes de texte de la section proviennent de philosophy.ts ; une modification du fichier (par exemple d'un principe) est reflétée dans la page sans modification de composant.
AC-6.3-07 Le build échoue si principles.length !== 3, si un identifiant est dupliqué ou si un champ dépasse sa borne.
AC-6.3-08 Contraste du texte courant et du lead ≥ 7:1 ; contraste de la 2ᵉ ligne du titre ≥ 3:1.
AC-6.3-09 Avec prefers-reduced-motion: reduce, aucune translation ni parallaxe ; contenu visible immédiatement ou après un fondu ≤ 0,2 s.
AC-6.3-10 Chaque révélation ne se joue qu'une fois ; un défilement vers le haut puis le bas ne la rejoue pas.
AC-6.3-11 Sans JavaScript, tout le texte est visible, à l'état final.
AC-6.3-12 Aucun événement de mesure n'est émis par cette section.
AC-6.3-13 Aucun CLS mesuré sur la section pendant les révélations (≤ 0,01 de contribution).
AC-6.3-14 Le texte n'est jamais coupé aux largeurs 320, 375, 768, 1024, 1440 et 1920 px ; aucun élément du texte ne se superpose à un autre, y compris l'exergue et le texte courant à 1024 px.
AC-6.3-15 Le padding-block calculé de la section vaut celui du jeton --spacing-section (§5.6.1) à chaque largeur testée ; aucune valeur en pixels propre à la section n'est déclarée.
AC-6.3-16 Le titre, l'exergue et les trois principes (identifiants et titres) sont identiques aux valeurs fixes de §7.5 ; le <h2> a l'identifiant philosophie-titre.

6.4 Méthode — Boussole de clarté (D) #

6.4.1 Objectif et rôle dans le parcours #

La méthode explique, en quatre temps, comment Malka accompagne : Écouter, Explorer, Clarifier, Décider. Elle prend la forme d'une boussole interactive : quatre étapes aux quatre points cardinaux, une aiguille qui se tourne vers l'étape choisie, un panneau de détail. Le visiteur la découvre au clic, au toucher ou au clavier, et au survol sur les appareils à pointeur précis. Elle est entièrement utilisable sans souris et lisible sans JavaScript.

Décision de structure : la boussole est un motif d'onglets accessible (role="tablist" / tab / tabpanel) à toutes les largeurs. Il n'y a pas d'accordéon mobile : la boussole garde sa forme réduite sur mobile et le panneau passe en dessous. Raison : une seule sémantique à tester et à maintenir, et le signe distinctif du site reste visible.

Correspondance des points cardinaux : Écouter = Nord (haut), Explorer = Est (droite), Clarifier = Sud (bas), Décider = Ouest (gauche). L'ordre horaire est l'ordre de la méthode. Fond sombre (forest-900 vers forest-950). Racine <section id="methode" aria-labelledby="methode-titre">.

6.4.2 Disposition par breakpoint #

Rythme vertical : jeton --spacing-section (§5.6.1). Fond : dégradé vertical forest-900 → forest-950, silhouette de crêtes en SVG (public/images/method/ridge.svg, trait décoratif à 8 % d'opacité, ancrée en bas, aria-hidden). Transitions douces haut et bas : 96 px de dégradé ivory-50 → forest-900 en haut de la section (variable --edge-light-to-dark, §5.4.1) ; 96 px de dégradé forest-950 → ivory-100 en bas (variable --edge-dark-to-light, fond de la section suivante). Ces dégradés sont dans la section et ne déplacent rien.

Desktop ≥ 1024 px #
Élément Colonnes Remarques
Eyebrow + <h2> « Boussole de clarté » 1 → 7 <h2> Fraunces clamp(2.5rem, 4.4vw, 3.75rem) ; le titre est en deux lignes max
Paragraphe d'introduction 8 → 12, aligné sur la base du titre Manrope 18 px, ivory-100, max 28 rem
Boussole 1 → 6 Boîte carrée, width: min(100%, 520px), aspect-ratio: 1, centrée dans ses colonnes
Panneau de détail 7 → 12 Centré verticalement par rapport à la boussole ; hauteur égale à celle du panneau le plus haut (voir pile des panneaux)
Ligne de consigne 1 → 6, sous la boussole Manrope 14 px, sage-200, centrée
Lien de clôture 7 → 12, sous le panneau Lien texte avec flèche, aligné à gauche
 Méthode (eyebrow)
 Boussole de clarté                                   Intro : deux phrases…
 ───────────────────────────────────────────────────────────────────────────
                (1) Écouter  [Nord]                           01 / 04
                      │                                       Écouter           (Fraunces 44)
   (4) Décider ──── ⊕ ──── (2) Explorer                       kicker italique
                      │                                       Résumé (18 px)…
              (3) Clarifier                                   – détail 1
                                                              – détail 2
        Consigne : choisissez une étape…                      ────────────
                                                              CE QUE CETTE ÉTAPE APPORTE
                                                              Phrase de synthèse
                                                              Découvrir les accompagnements →
Tablette 768–1023 px #

Colonne unique de 688 px max. Ordre : eyebrow, <h2>, introduction, boussole centrée (width: min(100%, 420px)), consigne, panneau (largeur 100 %, max 640 px, centré), lien de clôture.

Mobile < 768 px #

Colonne unique. Boussole width: min(100%, 300px) (minimum 272 px à 320 px de large), centrée, dans un conteneur qui ajoute 12 px de marge intérieure basse (l'étiquette du nœud Sud, placée sous son disque, ne sort jamais du conteneur). Nœuds de 48 px, étiquettes de 13 px. L'aiguille est raccourcie : sa pointe nord est à y = 140 et sa pointe sud à y = 260 (échelle 0,77 appliquée au SVG de l'aiguille), pour ne jamais toucher l'étiquette Nord. Panneau pleine largeur sous la boussole ; label de l'étape en Fraunces 32 px. Le lien de clôture est pleine largeur, hauteur 48 px.

Géométrie de la boussole #

Boîte position: relative. Un SVG décoratif (viewBox="0 0 400 400", aria-hidden="true", focusable="false") dessine le cadran. Les quatre onglets sont des <button> HTML positionnés par-dessus (meilleur rendu du texte, anneau de focus net, zone tactile précise).

Élément Géométrie (viewBox 400 × 400, centre 200,200)
Lunette extérieure cercle r = 170, trait ivory-50/10 1 px
Graduations 24 traits tous les 15°, de r = 158 à r = 170 (4 traits cardinaux plus longs : r = 150 à 170), ivory-50/25
Anneau de la boussole cercle r = 140, trait ivory-50/20 1,5 px
Quadrants d'arrivée 4 arcs r = 140 : N→E M200 60 A140 140 0 0 1 340 200, E→S M340 200 A140 140 0 0 1 200 340, S→O M200 340 A140 140 0 0 1 60 200, O→N M60 200 A140 140 0 0 1 200 60 ; trait 3 px, transparent par défaut ; l'arc qui arrive au nœud sélectionné est champagne-400 (Écouter : O→N ; Explorer : N→E ; Clarifier : E→S ; Décider : S→O)
Cercles intérieurs r = 100 (pointillé, ivory-50/10) et r = 40 (ivory-50/15, remplissage forest-950/60)
Halo dégradé radial centré, rayon 120, champagne-400/20 → transparent
Pivot cercle r = 8, champagne-400, au centre

Aiguille : second SVG (viewBox="0 0 400 400") dans un <div> plein cadre, tourné en CSS autour du centre de la boîte. Pointe nord : polygon 200,122 210,200 190,200 (champagne-400) ; pointe sud : polygon 200,278 210,200 190,200 (sage-400). Longueur 78 unités : la pointe n'atteint jamais l'étiquette du nœud (étiquettes placées sous les nœuds).

Positions des nœuds (centre du bouton, en pourcentage de la boîte) : Écouter (50 %, 15 %), Explorer (85 %, 50 %), Clarifier (50 %, 85 %), Décider (15 %, 50 %). Le bouton est centré sur ce point avec translate(-50%, -50%). Contenu du bouton : disque de 56 px (48 px sous 768 px) contenant le numéro (« 1 » à « 4 », Fraunces 22 px), et l'étiquette (Manrope 15 px, 13 px sous 768 px) sous le disque, margin-top: 6px. Zone cliquable : disque + étiquette (≥ 44 × 44 px).

États visuels d'un nœud :

État Rendu
Au repos Disque transparent, bordure 1 px ivory-50/40, numéro ivory-50, étiquette ivory-100
Sélectionné Disque plein champagne-400, numéro forest-950, étiquette champagne-200 en graisse 600
Survol (pointeur précis) ou aperçu Bordure champagne-400, transform: scale(1.06)
Focus clavier Anneau champagne-400 2 px + décalage 2 px, autour du disque
Actif (appui) scale(0.97)

La sélection est donc signalée par le remplissage et la graisse, pas par la couleur seule.

6.4.3 Source de contenu #

Fichier src/content/fr/method.ts. Le schéma qui fait foi, clé par clé, est celui de method dans §9.3.3 ; le tableau indique l'usage de chaque clé.

Clé (§9.3.3) Usage dans cette section
eyebrow Eyebrow au-dessus du titre (« Méthode »)
title <h2> : « Boussole de clarté »
intro Paragraphe d'introduction
tablistLabel Nom accessible de la liste d'onglets (« Les quatre temps de la méthode »)
instruction Ligne de consigne sous la boussole, masquée sans JavaScript
previewHint Indice affiché pendant un aperçu au survol
whatYouGetLabel Libellé du bloc de synthèse de chaque panneau (« Ce que cette étape apporte »)
closing (label, targetId) Lien de clôture vers accompagnements (« Découvrir les accompagnements »)
steps Exactement quatre étapes, dans l'ordre ecouter, explorer, clarifier, decider, chacune { id, label, verb, summary, details, whatYouGet }

Contraintes (schéma, voir §9.3.3) : exactement quatre étapes, dans l'ordre imposé, identifiants uniques, label valant respectivement « Écouter », « Explorer », « Clarifier », « Décider ». Les textes verb, summary, details et whatYouGet sont rédigés dans §7 ; ce sont des propositions à valider par Malka (registre de validation du contenu, clé copyDeck : la garde G-05 de §9.3.7 bloque la mise en production tant qu'elle n'est pas validée) ; ils décrivent ce que fait l'accompagnement, sans durée, sans nombre de séances, sans résultat garanti, sans allégation de santé. Le fichier method.ts est la source unique des libellés utilisés ailleurs : rail du hero (6.2), navigation interne, footer. Le type MethodStepId est celui de METHOD_STEP_IDS (§9.3.2).

6.4.4 Arborescence de composants et props #

MethodSection                         (serveur) src/components/sections/method/method-section.tsx
├─ MethodHeader                       (eyebrow, <h2 id="methode-titre">, intro ; <Reveal>)
├─ MethodExplorer                     ('use client', method-compass.tsx : état de sélection, aperçu, hash, mesure)
│  ├─ MethodHashAnchors               (4 × <span id="methode-<id>">)
│  ├─ CompassDial                     (SVG décoratif + 4 arcs + aiguille)
│  │  └─ CompassNeedle                (<div> tourné)
│  ├─ CompassTabs                     (role="tablist")
│  │  └─ CompassNode × 4              (role="tab")
│  ├─ MethodInstruction               (consigne ; masquée sans JS)
│  └─ MethodPanels                    (pile de 4 panneaux, tous montés, dans une même cellule de grille)
│     └─ MethodPanel × 4              (role="tabpanel" ; method-step-panel.tsx)
└─ MethodClosingLink                  (lien vers #accompagnements)
Hooks : useMethodSelection, useCompassKeys, usePreviewIntent (définis dans method-compass.tsx)

Le compas n'utilise pas StableSwap (réservé au résultat de l'orientation, 6.6) : les quatre panneaux restent montés en permanence et seul le panneau actif est animé.

export interface MethodSectionProps { content: Content['method'] }   // Content : type agrégé de §9.3.3

type MethodStep = Content['method']['steps'][number];

export interface MethodExplorerProps {
  steps: Content['method']['steps'];
  defaultStepId: MethodStepId;          // 'ecouter'
  labels: Pick<Content['method'], 'tablistLabel' | 'instruction' | 'previewHint' | 'whatYouGetLabel'>;
}

export interface CompassNodeProps {
  step: MethodStep;
  index: 0 | 1 | 2 | 3;                 // 0 = Nord, 1 = Est, 2 = Sud, 3 = Ouest
  selected: boolean;
  previewed: boolean;
  onSelect: (id: MethodStepId) => void;
  onPreviewStart: (id: MethodStepId) => void;
  onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
  tabIndex: 0 | -1;                     // tabindex itinérant
}

export interface MethodPanelProps {
  step: MethodStep;
  index: 0 | 1 | 2 | 3;
  selected: boolean;                    // étape choisie (sémantique ARIA)
  displayed: boolean;                   // étape affichée (choisie ou aperçue)
  whatYouGetLabel: string;
  hydrated: boolean;
}

Identifiants DOM (stables, utilisés par les tests et par les liens profonds) :

Rôle Identifiant
Ancre de lien profond methode-<id> (élément <span> vide, hors flux, voir 6.4.6)
Onglet methode-tab-<id>
Panneau methode-panel-<id>

Le panneau methode-panel-<id> porte aria-labelledby="methode-tab-<id>" ; l'onglet porte aria-controls="methode-panel-<id>".

Structure d'un panneau (dans cet ordre) : <h3> = label (Fraunces 44 px, 32 px sous 768) précédé de « 0N / 04 » (Manrope 12 px, champagne-400, aria-hidden) ; verb en Fraunces italique 20 px champagne-200 ; summary Manrope 18 px ivory-100 ; details en <ul> Manrope 16 px (puces : tiret demi-cadratin champagne-400) ; filet 1 px ivory-50/15 ; libellé whatYouGetLabel (Manrope 12 px, majuscules, champagne-400) ; whatYouGet en Fraunces 20 px ivory-50. Zone de l'indice d'aperçu : une ligne réservée au-dessus du <h3> (hauteur 20 px) qui n'affiche previewHint que pendant un aperçu (réservée en permanence pour éviter tout décalage).

Pile des panneaux. Les quatre MethodPanel sont montés en permanence dans la même cellule de grille (grid-area: 1 / 1), sans copie de calibrage. La hauteur de la cellule est donc celle du plus haut des quatre : aucun décalage de mise en page au changement d'étape, à aucune largeur. Le panneau actif s'anime en opacité (avec une translation verticale de 8 px) ; les panneaux inactifs sont visibility: hidden et inert une fois l'hydratation faite.

États ARIA et visuels des panneaux (c'est la table normative) :

Choisi (selected) Affiché (displayed) Rendu visuel Exposition aux technologies d'assistance
oui oui opacité 1, visible, tabIndex={0} exposé
oui non (un autre est en aperçu) opacité 0, mais ni visibility: hidden ni inert, pointer-events: none exposé (il reste le panneau officiel de l'onglet choisi)
non oui (aperçu) opacité 1, visible aria-hidden="true"
non non opacité 0 puis visibility: hidden à la fin de la transition inert et aria-hidden="true"

Avant l'hydratation (rendu serveur), aucun panneau n'a aria-hidden ni inert (voir « Sans JavaScript » en 6.4.5), et les panneaux non choisis sont simplement à opacity: 0 dans la cellule commune ; aria-hidden, inert et visibility: hidden sont appliqués après hydratation (hydrated === true). Les composants rendent toujours le même arbre, que le mouvement soit activé ou non : le profil de mouvement ne modifie que des valeurs, jamais le nombre ni le type des éléments.

6.4.5 États #

État Comportement
Rendu serveur Boussole et quatre panneaux dans le HTML. Étape choisie par défaut : ecouter (onglet aria-selected="true", tabIndex=0 ; autres aria-selected="false", tabIndex=-1). Aiguille à 0°. Panneaux non masqués aux lecteurs d'écran (ni aria-hidden ni inert).
Après hydratation Lecture du hash (6.4.6) ; si #methode-<id> valide, l'étape correspondante est choisie avant la première peinture (effet de mise en page). aria-hidden, inert et visibility: hidden appliqués aux panneaux inactifs (selon la table normative de 6.4.4).
Aperçu (survol souris) Le panneau de l'étape survolée est affiché ; le nœud survolé reçoit l'état « aperçu » ; la ligne d'indice affiche previewHint ; l'étape choisie et l'aiguille ne changent pas.
Sélection Disque rempli, étiquette en gras, arc d'arrivée en champagne-400, aiguille en rotation vers le nœud, panneau en fondu croisé.
Mouvement réduit Aiguille positionnée instantanément ; panneaux en fondu croisé instantané (pas de translation) ; pas d'échelle au survol. L'aperçu au survol reste actif.
Mobile / tactile Aucun aperçu ; toucher = sélection ; aucune règle :hover appliquée aux nœuds (cadrées par @media (hover: hover) and (pointer: fine)).
Hors écran Aucune boucle ambiante propre à cette section n'est requise.
Erreur Il n'existe pas d'état d'erreur : tous les contenus sont statiques et validés au build.
Vide Impossible (4 étapes obligatoires, schéma).
Désactivé Aucun nœud n'est désactivé.

Sans JavaScript. Les quatre étapes sont toutes développées et lisibles, sans perte de contenu. Règles dans src/styles/globals.css :

@media (scripting: none) {
  .method-compass,
  .method-instruction,
  .method-preview-hint { display: none; }

  .method-panels {
    grid-column: 1 / -1;
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 40px 32px;
  }
  .method-panel {
    grid-area: auto;
    opacity: 1;
    pointer-events: auto;
  }
}
@media (scripting: none) and (max-width: 767.98px) {
  .method-panels { grid-template-columns: minmax(0, 1fr); }
}

Chaque panneau contient son propre <h3> : il est autonome. Les liens #methode-<id> du hero mènent à la zone des panneaux. Sans JavaScript, la zone des panneaux occupe toute la largeur du conteneur (la boussole étant masquée).

6.4.6 Interactions (souris, tactile, clavier) #

Sélection par clic ou toucher. Un clic (ou un toucher) sur un nœud choisit l'étape : selectedId = id, aperçu effacé, aiguille tournée, fond mis à jour, hash de l'URL mis à jour (voir plus bas). Le focus reste sur le nœud. Aucun défilement automatique.

Aperçu au survol (pointeur précis uniquement). Conditions cumulatives : event.pointerType === 'mouse' et matchMedia('(hover: hover) and (pointer: fine)').matches. Sur pointerenter d'un nœud, un minuteur de 120 ms (PREVIEW_DELAY_MS) démarre ; à l'expiration, previewId = id (sauf si c'est déjà l'étape choisie). Un pointerleave du nœud avant l'expiration annule le minuteur. Pour garder le contenu atteignable à la souris (WCAG 1.4.13), l'aperçu n'est pas effacé quand le pointeur quitte le nœud : il l'est quand le pointeur quitte l'ensemble boussole + panneau (pointerleave du conteneur MethodExplorer), quand une étape est choisie (clic, clavier), quand le focus clavier entre dans le panneau ou le lien de clôture, ou à la touche Échap (l'aperçu est ignorable ; Échap n'est pas intercepté s'il n'y a pas d'aperçu). Un survol d'un autre nœud remplace l'aperçu après 120 ms. Sur un appareil hybride, le toucher d'un nœud n'engendre jamais d'aperçu (le pointerType est touch).

Clavier (motif d'onglets, activation automatique). Le focus entre dans la liste d'onglets par Tab sur l'onglet choisi (tabindex itinérant). Dans la liste :

Touche Action
Flèche droite / flèche bas Étape suivante (la dernière revient à la première)
Flèche gauche / flèche haut Étape précédente (la première passe à la dernière)
Début (Home) Première étape (Écouter)
Fin (End) Dernière étape (Décider)
Entrée / Espace Choisit l'étape focalisée (sans effet si elle l'est déjà)
Tab Sort de la liste vers le panneau choisi (qui a tabIndex=0, car il ne contient aucun élément focalisable), puis vers le lien de clôture
Échap Efface un éventuel aperçu

L'activation est automatique : déplacer le focus avec les flèches choisit l'étape, car le changement est instantané. preventDefault() est appelé sur les flèches, Début et Fin uniquement lorsqu'un onglet a le focus. Les quatre flèches sont acceptées (la disposition est en croix) mais ne dépendent pas de la position géométrique : droite/bas = suivant, gauche/haut = précédent.

function nextIndex(key: string, i: number, n = 4): number | null {
  switch (key) {
    case 'ArrowRight': case 'ArrowDown': return (i + 1) % n;
    case 'ArrowLeft':  case 'ArrowUp':   return (i - 1 + n) % n;
    case 'Home': return 0;
    case 'End':  return n - 1;
    default: return null;
  }
}

Rotation de l'aiguille. L'angle est cumulatif et suit le plus court chemin : target = index * 90 ; delta = (((target - current) % 360) + 540) % 360 - 180 ; si delta === -180, on prend +180 ; current += delta. Ainsi Décider → Écouter fait +90°, jamais -270°, et Écouter → Décider fait -90°.

Liens profonds #methode-<id>.

  • Quatre éléments <span id="methode-ecouter"> … <span id="methode-decider"> sont placés tout en haut de MethodExplorer, avec display: block; height: 0; scroll-margin-top: calc(var(--header-h) + 16px), aria-hidden="true". Le navigateur y fait défiler nativement : la zone de la boussole arrive sous l'en-tête.
  • Expression d'analyse : /^#methode-(ecouter|explorer|clarifier|decider)$/.
  • Au montage (avant la première peinture) : si le hash correspond, l'étape est choisie, sans événement de mesure. Si le hash ne correspond pas (par exemple #methode, #methode-inconnu), l'étape par défaut ecouter est conservée.
  • Écouteur hashchange : si le nouveau hash correspond, l'étape est choisie et un événement de mesure est émis (c'est une intention du visiteur, par exemple un clic sur le rail du hero). Sinon, rien.
  • Quand le visiteur choisit une étape dans la boussole, le hash est remplacé par history.replaceState(null, '', '#methode-<id>') (pas d'ajout dans l'historique, pas de hashchange). Un lien partagé après sélection ouvre donc la même étape.
  • Les hashs en majuscules (#Methode-Clarifier) ne correspondent pas et sont ignorés.

6.4.7 Accessibilité #

  • Liste d'onglets : role="tablist", aria-label = tablistLabel, sans aria-orientation (valeur par défaut horizontale ; la navigation par flèches ci-dessus reste cohérente). Onglets dans l'ordre DOM Écouter, Explorer, Clarifier, Décider (sens horaire = ordre de la méthode).
  • Onglet : role="tab", aria-selected, aria-controls, tabIndex itinérant, id. Nom accessible = libellé de l'étape (le numéro est aria-hidden).
  • Panneau : role="tabpanel", aria-labelledby, tabIndex={0} pour le panneau choisi uniquement ; exposition ARIA selon la table normative de 6.4.4.
  • Le SVG du cadran, l'aiguille, les graduations : aria-hidden="true", aucun texte.
  • Aperçu : pas de aria-live (le contenu changeant uniquement au survol souris ne doit pas être annoncé ; l'état choisi, lui, est exposé en permanence). Le changement de sélection au clavier est annoncé par le lecteur d'écran via le focus et aria-selected.
  • Focus visible sur chaque nœud et sur le panneau choisi (anneau champagne-400, 2 px, décalage 2 px).
  • Contrastes : ivory-100 sur forest-900 ≥ 12:1 ; champagne-200 sur forest-900 ≥ 9:1 ; bordure des nœuds au repos ivory-50/40 sur forest-900 ≥ 3:1 (élément graphique).
  • Tailles de cible : nœuds ≥ 48 × 48 px (touchant le contour du disque et l'étiquette) ; lien de clôture ≥ 44 px.
  • Aucun contenu uniquement au survol : toute information de l'aperçu est atteignable par clic, toucher ou clavier.
  • Mode forced-colors : disque sélectionné rempli en Highlight, étiquette en HighlightText ; bordures des nœuds visibles.
  • Les ancres methode-<id> sont aria-hidden ; elles ne sont pas des repères pour les technologies d'assistance.

6.4.8 Mouvement #

Références dans §8 : révélation de l'en-tête de section et de la boussole à l'entrée dans le viewport, rotation de l'aiguille (durée slow, courbe easeClarte), fondu des arcs d'arrivée (opacité, durée fast), changement de panneau en fondu croisé (durée fast, translation verticale de 8 px) — l'aperçu utilise un fondu de durée instant sans translation —, micro-interactions des nœuds (échelle, ressort de §8). Propriétés animées : transform et opacity uniquement. Mouvement réduit : voir 6.4.5.

6.4.9 Événements de mesure #

method_step_open avec { step: <id> } (valeur : ecouter, explorer, clarifier ou decider) à chaque changement d'étape choisie initié par le visiteur : clic, toucher, touches fléchées, Début/Fin, hashchange provenant d'un lien. Règles : pas d'événement au montage (lecture du hash initial) ; pas d'événement pour un simple aperçu au survol ; un événement par rafale de navigation clavier, émis 400 ms après la dernière sélection (anti-rafale) ; pas d'événement si l'étape finale est identique à la dernière étape mesurée. Aucune autre donnée n'est transmise (voir §9.8). Les liens du rail du hero ne produisent pas d'événement propre : l'ouverture d'étape est mesurée ici.

6.4.10 Cas limites #

Cas Comportement
Hash #methode-clarifier au chargement Étape « Clarifier » choisie avant la première peinture post-hydratation ; l'aiguille est déjà à 180° (pas d'animation initiale) ; le navigateur a défilé vers l'ancre
Hash invalide ou en majuscules Ignoré, étape par défaut
Navigation Précédent/Suivant entre #methode-x et #methode-y hashchange : sélection mise à jour, mesure émise
Clic sur un lien du rail dont l'étape est déjà choisie et l'URL identique Pas de hashchange : le défilement natif a lieu, la sélection ne change pas, pas de mesure
Rafale rapide de clics Chaque clic choisit l'étape ; l'animation de l'aiguille est interrompue et repart de l'angle courant ; un seul événement de mesure par rafale
Redimensionnement de mobile à desktop (ou l'inverse) Aucun remontage ; sélection et aiguille conservées ; la géométrie est en pourcentages
Rotation d'écran Idem
Appareil hybride (souris + tactile) L'aperçu ne s'active que pour pointerType === 'mouse' ; le toucher sélectionne directement
Pointeur qui sort de la fenêtre pendant un aperçu pointerleave du conteneur : l'aperçu est effacé
Panneau plus long (4 puces à la borne maximale du schéma) La pile prend la hauteur du plus haut ; tous les autres états gardent cette hauteur
Texte agrandi 200 % Les panneaux restent dans la grille ; aucune coupure ; la boussole garde son ratio
Pas de JavaScript Quatre étapes développées en deux colonnes (une colonne sous 768 px)
Lecteur d'écran en mode aperçu Non concerné : le panneau choisi reste exposé même invisible (table de 6.4.4)
Étape sélectionnée par l'URL puis clic sur Échap Pas d'effet (pas d'aperçu à effacer)
Fonctionnement sans matchMedia L'aperçu est désactivé ; les autres interactions fonctionnent

6.4.11 Critères d'acceptation #

ID Critère
AC-6.4-01 Le HTML servi contient quatre role="tab", quatre role="tabpanel" avec leur contenu complet, et l'onglet « Écouter » est aria-selected="true".
AC-6.4-02 La liste d'onglets a role="tablist" et un aria-label ; les identifiants suivent methode-tab-<id> / methode-panel-<id> ; chaque panneau est aria-labelledby de son onglet ; chaque onglet est aria-controls de son panneau.
AC-6.4-03 Tabindex itinérant : un seul onglet a tabIndex=0 à tout moment (l'onglet choisi), les trois autres -1.
AC-6.4-04 Flèche droite sur « Écouter » choisit « Explorer » ; flèche gauche sur « Écouter » choisit « Décider » ; flèche bas équivaut à droite ; flèche haut à gauche ; Fin choisit « Décider » ; Début choisit « Écouter ».
AC-6.4-05 Après Tab depuis l'onglet choisi, le focus arrive sur le panneau choisi (tabIndex=0), puis sur le lien de clôture.
AC-6.4-06 Sur un navigateur avec pointeur précis, survoler « Clarifier » plus de 120 ms affiche son panneau et la ligne d'indice ; aria-selected ne change pas ; quitter l'ensemble boussole + panneau restaure le panneau choisi ; déplacer le pointeur du nœud vers le panneau conserve l'aperçu.
AC-6.4-07 Avec un contexte tactile émulé (hasTouch, pas de survol), aucun aperçu ne se produit ; un toucher choisit l'étape.
AC-6.4-08 Pour un panneau en aperçu (non choisi) : aria-hidden="true" ; le panneau choisi, invisible pendant l'aperçu, n'a ni aria-hidden ni inert. Les panneaux inactifs et non aperçus ont inert, aria-hidden="true" et visibility: hidden après l'hydratation ; les quatre panneaux restent montés dans le DOM à tout moment.
AC-6.4-09 Le chargement de /#methode-clarifier choisit « Clarifier » (aria-selected), positionne l'aiguille à 180° sans animation et fait défiler la boussole sous l'en-tête.
AC-6.4-10 Un hashchange vers #methode-decider choisit « Décider » et émet method_step_open avec step = 'decider'.
AC-6.4-11 Un clic sur une étape remplace le hash par #methode-<id> sans ajouter d'entrée d'historique (history.length inchangé).
AC-6.4-12 Un hash #methode-foo ou #Methode-Ecouter laisse « Écouter » choisi.
AC-6.4-13 Rotation de l'aiguille : de Décider vers Écouter, l'angle cumulatif augmente de 90° (jamais de -270°).
AC-6.4-14 La hauteur de la zone des panneaux ne varie pas au changement d'étape (écart ≤ 1 px) aux largeurs 320, 768, 1024 et 1440 px.
AC-6.4-15 Avec JavaScript désactivé, les quatre panneaux sont visibles simultanément, la boussole et la consigne sont masquées, aucun contenu n'est perdu ; en dessous de 768 px la grille est à une colonne.
AC-6.4-16 Avec prefers-reduced-motion: reduce, l'aiguille se positionne sans transition, le changement de panneau est instantané, aucune échelle n'est appliquée au survol.
AC-6.4-17 Chaque nœud a une zone interactive d'au moins 44 × 44 px, y compris à 320 px (boussole de 272 px).
AC-6.4-18 À 320 px, aucun nœud ni étiquette ne déborde du conteneur de la boussole (y compris l'étiquette « Clarifier ») ; aucune étiquette ne chevauche l'aiguille (raccourcie sous 768 px) ni une autre étiquette (rectangles englobants, en position Nord).
AC-6.4-19 method_step_open est émis une fois par changement d'étape visiteur, pas au chargement, pas à l'aperçu ; une rafale de 5 flèches en 300 ms produit un seul événement, avec la dernière étape.
AC-6.4-20 Le contraste de l'étiquette et des textes des panneaux est ≥ 4,5:1 sur forest-900 ; l'anneau de focus est visible sur chaque nœud et sur le panneau.
AC-6.4-21 Le build échoue si steps n'est pas de longueur 4, si l'ordre des identifiants diffère de ecouter, explorer, clarifier, decider, ou si un champ dépasse sa borne.
AC-6.4-22 Aucune violation axe avec chaque étape choisie, y compris pendant un aperçu.
AC-6.4-23 Le lien de clôture mène à #accompagnements et mesure ≥ 44 px de haut.
AC-6.4-24 Le titre de la section est « Boussole de clarté » (<h2 id="methode-titre">) ; à 1024 px et plus, la boussole occupe les colonnes 1 à 6 et le panneau les colonnes 7 à 12 ; les nœuds mesurent 56 px (48 px sous 768 px) ; le padding-block calculé est celui du jeton --spacing-section.

6.5 Accompagnements (E) #

6.5.1 Objectif et rôle dans le parcours #

Présenter clairement les quatre accompagnements, leur tarif et la façon d'entrer en contact, sans effet de « pile de cartes ». C'est la section de conversion principale : chaque offre mène directement à WhatsApp avec un message pré-rempli adapté à l'offre, ou, au choix, au formulaire dont le champ « offre » est pré-sélectionné. Pas de paiement en ligne, pas de calendrier à l'ouverture (l'extension Cal.com ultérieure passe par getBookingAction, voir §9.9).

Offres de lancement (ordre d'origine, tarifs en euros) :

Rang Identifiant Titre Tarif
1 seance-clarification Séance de clarification ciblée 300 € (mise en avant)
2 lecture-globale Lecture globale de l'entrepreneur et de l'entreprise 150 €
3 suivi-transition Suivi de transition sur six mois 1 000 €
4 accompagnement-personnalise Accompagnement personnalisé Sur devis

Fond ivory-100. Racine <section id="accompagnements" aria-labelledby="accompagnements-titre">.

6.5.2 Disposition par breakpoint #

Rythme vertical : jeton --spacing-section (§5.6.1).

Décision de composition (anti « pile de cartes ») #

Une seule offre est un panneau encadré : l'offre mise en avant (featured). Les autres offres sont des lignes éditoriales séparées par des filets fins (1 px forest-800/15) sur le fond de la section, sans cadre, sans ombre et sans arrière-plan propre. Il n'y a pas de grille de cartes et aucune contrainte de hauteur égale entre les offres : chaque ligne a la hauteur de son contenu. Le rythme visuel vient de l'asymétrie (grand panneau sombre à gauche, liste aérée à droite), pas de la répétition de rectangles identiques. Un seul composant, OfferItem, rend toutes les offres, en deux variantes : panel (offre mise en avant) et row (ligne éditoriale).

Deux dispositions existent, choisies par la donnée (fonction getOffersLayout, voir 6.5.4) :

Disposition Condition Rendu desktop
featured Exactement une offre featured: true et au moins deux offres Panneau sombre à gauche (colonnes 1 → 6) sur toute la hauteur de la liste ; les autres offres en lignes à droite (colonnes 7 → 12)
even Aucune offre mise en avant, ou une seule offre au total Grille de 2 colonnes (1 colonne s'il n'y a qu'une offre, largeur max 720 px) de lignes à filet supérieur, sans panneau sombre

Le schéma interdit plus d'une offre featured (voir 6.5.3).

Ordre d'affichage. En disposition featured, l'offre mise en avant est rendue en premier dans le DOM, puis les autres par order croissant. En disposition even, toutes par order croissant. L'ordre du DOM est l'ordre visuel et l'ordre de tabulation à toutes les largeurs. Le numéro affiché (« 01 », « 02 »…) est le rang dans l'ordre d'affichage.

Desktop ≥ 1024 px #

Grille de 12 colonnes. En-tête de section : eyebrow + <h2> en colonnes 1 → 8, introduction en colonnes 9 → 12 (alignée sur la base du titre).

 Accompagnements (eyebrow)
 Titre de section (cols 1–8)                                   Introduction (cols 9–12)
 ───────────────────────────────────────────────────────────────────────────────────
 ┌──────────────────────────────────┐   02  Lecture globale de l'entrepreneur…   Tarif 150 €
 │ POUR FAIRE LE POINT   (badge)    │       Description courte
 │ 01                               │       Voir le détail ▾
 │ Séance de clarification ciblée   │       [ Écrire sur WhatsApp ]  Écrire via le formulaire
 │ Description courte…              │   ───────────────────────────────────────────────────
 │  – détail 1                      │   03  Suivi de transition sur six mois   Tarif 1 000 €
 │  – détail 2                      │       Description courte
 │  À distance                      │       Voir le détail ▾
 │  Tarif                           │       [ Écrire sur WhatsApp ]  Écrire via le formulaire
 │  300 €                           │   ───────────────────────────────────────────────────
 │ [ Écrire sur WhatsApp ]          │   04  Accompagnement personnalisé    Tarif Sur devis
 │ Écrire via le formulaire         │       Description courte
 └──────────────────────────────────┘       [ Écrire sur WhatsApp ]  Écrire via le formulaire
 ───────────────────────────────────────────────────────────────────────────────────
 Mention de TVA (issue de legal.ts)                   Phrase d'orientation (orientationHint) → lien vers la boussole

Panneau mis en avant (OfferItem variante panel) : grid-column: 1 / span 6, grid-row: 1 / span (n − 1) (n = nombre d'offres), align-self: stretch, fond forest-900, texte ivory-50 / ivory-100, rayon 20 px, padding 40 px, halo radial champagne-400/15 dans l'angle supérieur droit. Lignes (OfferItem variante row) : grid-column: 7 / span 6, padding vertical 32 px, filet supérieur 1 px (la première ligne n'a pas de filet supérieur ; la dernière a un filet inférieur).

Contenu de chaque ligne (grille interne [3rem_1fr_auto]) : numéro (colonne 1) ; titre <h3> Fraunces 28 px, description, méta, détail, actions (colonne 2) ; bloc « Tarif » aligné à droite en haut (colonne 3).

Disposition even : grille lg:grid-cols-2, gap-x-16, chaque ligne a un filet supérieur de 1 px et le même contenu que les lignes de la disposition featured (variante row, sans panneau sombre).

Tablette 768–1023 px #

Colonne unique de 688 px max. Panneau mis en avant en pleine largeur (padding 32 px), puis les autres offres en lignes pleine largeur. Chaque ligne : grille [1fr_auto] (titre et description à gauche, bloc « Tarif » à droite), actions sous le contenu. Disposition even : mêmes lignes, sans panneau.

Mobile < 768 px #

Colonne unique. Panneau mis en avant (padding 28 px) puis lignes. Dans chaque ligne : numéro et titre, ligne de tarif (« Tarif » + montant) sous le titre, description, méta, détail, boutons. Bouton WhatsApp en pleine largeur, hauteur 48 px ; lien secondaire en dessous, zone tactile de 44 px de haut. Mention de TVA et lien d'orientation en bas, empilés.

6.5.3 Source de contenu #

Fichier src/content/fr/offers.ts (données des offres et textes de section) et legal.ts (mention de TVA). Le schéma qui fait foi, clé par clé, est celui de offers dans §9.3.3 ; les tableaux ci-dessous indiquent l'usage de chaque clé.

Clé de section (§9.3.3) Usage dans cette section
eyebrow, title, intro Eyebrow, <h2> et introduction
featuredLabel Badge de l'offre mise en avant (« Pour faire le point »)
priceLabel Libellé visible « Tarif »
detailOpenLabel, detailCloseLabel Libellés du résumé <details> (« Voir le détail » / « Masquer le détail »)
ctaLabels.whatsapp, ctaLabels.whatsappNewTabSuffix Libellé du bouton WhatsApp (« Écrire sur WhatsApp ») et complément masqué (ouverture dans un nouvel onglet)
formLinkLabel Libellé du lien vers le formulaire (« Écrire via le formulaire »)
orientationHint (text, linkLabel) Phrase et lien vers #orientation
offers Liste des offres (ci-dessous)
Clé d'offre (§9.3.3) Usage
id Identifiant unique (minuscules, chiffres, tirets) ; sert de valeur au champ « offre » du formulaire
title, shortDescription <h3> et description courte
details Puces du <details> (liste vide : pas de <details>)
duration Affichée seulement si fournie
format Valeur fixe « À distance » pour les quatre offres de lancement
price { kind: 'fixed', amountEur } ou { kind: 'quote', label }
whatsappMessage Message pré-rempli du lien WhatsApp (texte de §7, commençant par « Bonjour Malka » et nommant l'offre)
featured, order Mise en avant (au plus une offre) et rang d'affichage

Règles de validation exigées (schéma de §9.3.3 et vérifications croisées de §9.3.5) : 1 à 6 offres ; id et order uniques ; au plus une offre featured ; chaque price.amountEur entier ≥ 1 ; chaque whatsappMessage non vide et dont l'URL encodée complète fait ≤ 1 900 caractères ; aucun champ de texte vide après trim(). L'énumération offerId du schéma de contact (voir §9.4) est dérivée de cette liste (z.enum([...ids, 'non-defini'])) : ajouter ou retirer une offre dans offers.ts met à jour le formulaire sans autre modification. Le formulaire n'a aucune clé de message pré-rempli : le champ « message » n'est jamais prérempli.

Valeurs de lancement : titres et tarifs du tableau de 6.5.1 ; featured: true uniquement pour seance-clarification ; price de accompagnement-personnalise = { kind: 'quote', label: 'Sur devis' } ; format = « À distance » (valeur fixe). duration reste absent tant que Malka ne fournit pas d'information (aucune durée n'est inventée). shortDescription, details et les messages WhatsApp sont des propositions rédigées dans §7, à valider par Malka (registre de validation du contenu, clé copyDeck : la garde G-05 de §9.3.7 bloque la mise en production tant qu'elle n'est pas validée) ; aucun ne contient de promesse de résultat.

Mise en forme du tarif. formatPrice(price) (dans src/lib/format/price.ts) :

const eur = new Intl.NumberFormat('fr-FR', {
  style: 'currency', currency: 'EUR', maximumFractionDigits: 0,
});

export function formatPrice(price: Offer['price']): string {   // Offer : type de §9.3.3
  return price.kind === 'fixed' ? eur.format(price.amountEur) : price.label;
}

Sorties attendues, telles que produites par Intl (aucun remplacement manuel des espaces) : 300 €, 150 €, 1 000 € (espace fine insécable comme séparateur de milliers, espace insécable avant le symbole). Le montant est rendu dans un <span class="whitespace-nowrap"> pour ne jamais être coupé. Le libellé « Tarif » (priceLabel) est visible et précède le montant. Aucune mention « TTC » ou « HT » n'est affichée dans le bloc : le statut de TVA est celui de la ligne légale ci-dessous.

Ligne légale de TVA. Sous la liste, un paragraphe affiche legal.vatMention tel que fourni par Malka (Manrope 14 px, ink-600, filet supérieur 1 px). Cette valeur n'est jamais inventée. En APP_ENV=production, le build échoue si elle est vide (garde G-01 de §9.3.7). En développement et en préproduction, si elle est vide, un repère visible data-dev-placeholder remplace la ligne : « Mention de TVA à fournir par Malka avant mise en production » (bordure en tirets couleur error).

6.5.4 Arborescence de composants et props #

Les noms de fichiers sont ceux de §12.2 (kebab-case, sous src/components/sections/offers/) ; chaque composant porte le nom PascalCase de son export.

OffersSection                         (S) offers-section.tsx
├─ OffersHeader                       (eyebrow, <h2 id="accompagnements-titre">, intro ; <Reveal>)
├─ OfferList                          (<ol>, disposition 'featured' | 'even')
│  └─ OfferItem × n                   (S) offer-item.tsx — variantes panel | row ; <li><article aria-labelledby>
│     ├─ OfferHeading                 (numéro aria-hidden, <h3>, badge « featuredLabel »)
│     ├─ OfferPrice                   (libellé « Tarif » visible + montant ou « Sur devis »)
│     ├─ OfferMeta                    (format · durée éventuelle)
│     ├─ <p> shortDescription
│     ├─ OfferDetails                 (<details> natif)
│     └─ OfferActions
│        ├─ CtaLink                   (ui/cta-link.tsx — lien WhatsApp, href reçu en prop)
│        └─ OfferFormLink             ('use client', offer-form-link.tsx : prérempli puis défilement)
├─ VatMention                         (vat-mention.tsx — legal.vatMention)
└─ OrientationHint                    (texte + lien vers #orientation)
export type OffersLayout = 'featured' | 'even';

/** Pure (lib/content/helpers.ts, §9.3.4), testée unitairement. Ne mute pas l'entrée. */
export function getOffersLayout(offers: readonly Offer[]): {
  layout: OffersLayout;
  ordered: Offer[];
} {
  const byOrder = [...offers].sort((a, b) => a.order - b.order);
  const featured = byOrder.filter((o) => o.featured);
  if (featured.length === 1 && byOrder.length >= 2) {
    return { layout: 'featured', ordered: [featured[0], ...byOrder.filter((o) => !o.featured)] };
  }
  return { layout: 'even', ordered: byOrder };
}

export interface OffersSectionProps {
  content: Content['offers'];      // Content, Offer : types de §9.3.3
  vatMention: string;
  showDevPlaceholder: boolean;     // vatMention vide && APP_ENV !== 'production'
}

export interface OfferItemProps {
  offer: Offer;
  rank: number;                    // 1-based dans l'ordre d'affichage
  layout: OffersLayout;
  variant: 'panel' | 'row';
  labels: Pick<Content['offers'], 'featuredLabel' | 'priceLabel' | 'detailOpenLabel' | 'detailCloseLabel' | 'formLinkLabel'>;
  whatsapp: { href: string; label: string; newTabSuffix: string };   // résultat de getBookingAction({ offerId }) calculé côté serveur
  formHref: string;                // buildContactHref(offer.id) : '/?offre=<id>#contact'
}

export interface OfferFormLinkProps {
  offerId: string;
  offerTitle: string;
  href: string;
  label: string;
}

OffersSection (composant serveur) appelle getBookingAction({ offerId }) pour chaque offre et passe { href, label, newTabSuffix } à OfferItem ; aucun îlot client de cette section n'importe lib/booking/index.ts et aucun composant ne construit une URL WhatsApp. Le libellé du bouton est ctaLabels.whatsapp (« Écrire sur WhatsApp »). Les clics de suivi utilisent le composant CtaLink de §9.8.

Contrat de préremplissage du formulaire (mécanisme retenu). Pas de rechargement de page, pas de useSearchParams (qui ferait perdre le rendu statique du formulaire), et jamais de donnée de situation dans une adresse. Module src/lib/contact/prefill.ts, importable côté client, partagé avec la boussole d'orientation (6.6) et le formulaire (6.9) :

export const CONTACT_PREFILL_EVENT = 'contact:prefill' as const;

export interface ContactPrefill {
  offerId?: string;        // identifiant d'offre ou 'non-defini'
  situationId?: string;    // transmis par 6.6 uniquement ; jamais écrit dans une adresse
}

/** '/?offre=seance-clarification#contact' : seul l'identifiant d'offre peut figurer dans une adresse. */
export function buildContactHref(offerId: string): string;

/** Lit window.location.search ; ignore silencieusement toute valeur hors liste autorisée. */
export function parsePrefill(search: string, validOfferIds: readonly string[]): { offerId?: string };

/** Magasin en mémoire (module) + CustomEvent + défilement + focus (voir ci-dessous). */
export function requestContactPrefill(p: ContactPrefill): void;

/** Lit puis vide le magasin en mémoire ; appelé par le formulaire au montage. */
export function takePendingPrefill(): ContactPrefill | null;

6.5.5 États #

État Comportement
Rendu serveur Toutes les offres, tous les prix, tous les boutons et liens (avec leurs href finaux) sont dans le HTML ; les <details> des offres non mises en avant sont fermés, celui de l'offre mise en avant est ouvert (open)
Disposition featured Voir 6.5.2
Disposition even Pas de panneau sombre ; lignes à filet supérieur en deux colonnes
Prix fixe / sur devis Montant formaté ou libellé price.label ; la taille du libellé « Sur devis » est 28 px (36 px pour un montant)
Une seule offre Colonne de 720 px max, sans numéro de rang
Survol d'une ligne (pointeur précis) Une barre verticale de 2 px champagne-400 à gauche de la ligne se déploie (échelle verticale, origine haut) ; aucun déplacement de contenu et aucun soulèvement de la ligne
Focus dans une ligne Même barre que le survol (:focus-within), sans soulèvement
Bouton WhatsApp : repos / survol / focus / actif Repos : fond forest-800, texte ivory-50 (sur panneau sombre : fond champagne-400, texte forest-950) ; survol : fond forest-700 (resp. champagne-200), l'icône MessageCircle glisse de 2 px (classe .motion-arrow) ; focus : anneau (turquoise sur clair, champagne sur sombre) ; actif : scale(0.98) (classe .motion-press)
Détail ouvert / fermé Libellé « Voir le détail » / « Masquer le détail » (deux <span> commutés par details[open]), chevron Lucide ChevronDown tourné de 180° en CSS à l'ouverture
Mouvement réduit Apparitions en fondu ≤ 0,2 s ou immédiates, pas de translation ; la barre de survol apparaît sans transition ; le chevron tourne sans transition ; les classes de survol et d'appui sont neutralisées
Mobile Aucun effet de survol ; boutons pleine largeur
Mention de TVA absente (hors production) Repère de développement visible (6.5.3)
Chargement / erreur / désactivé Sans objet : contenu statique ; liens toujours actifs ; si un href WhatsApp ne peut pas être construit, le build échoue (voir 6.5.10)
Sans JavaScript Tout fonctionne : WhatsApp (liens), détails natifs, lien « formulaire » = chargement de /?offre=<id>#contact (le préremplissage du champ « offre » nécessite JavaScript ; sans lui, le visiteur arrive sur le formulaire non prérempli)

6.5.6 Interactions (souris, tactile, clavier) #

Bouton « Écrire sur WhatsApp ». <a href="https://wa.me/33667295179?text=…" target="_blank" rel="noopener noreferrer"> (l'URL est produite par getBookingAction, jamais par un composant). Texte visible « Écrire sur WhatsApp », suivi d'un texte masqué visuellement : à propos de « {title} » puis le complément ctaLabels.whatsappNewTabSuffix (« (ouvre WhatsApp dans un nouvel onglet) »). Souris : clic ouvre un nouvel onglet. Tactile : ouvre l'application WhatsApp ou WhatsApp Web selon l'appareil. Clavier : Entrée. Le message pré-rempli est offer.whatsappMessage encodé par encodeURIComponent dans getBookingAction.

Lien « Écrire via le formulaire ». <a href="/?offre=<id>#contact"> (lien réel : fonctionne sans JavaScript, un clic droit, un clic avec Ctrl/Cmd ou le clic du milieu ouvrent la page dans un nouvel onglet, et l'adresse suffit au formulaire pour se pré-remplir). Au clic simple (bouton principal, sans Ctrl/Cmd/Maj/Alt), OfferFormLink :

  1. appelle event.preventDefault() ;
  2. appelle requestContactPrefill({ offerId }), qui : (a) écrit la demande dans le magasin en mémoire du module ; (b) émet CustomEvent('contact:prefill', { detail: { offerId } }) sur window ; (c) place l'entrée d'historique #contact (history.pushState(null, '', '#contact') : aucun paramètre d'adresse n'est ajouté, et le bouton Précédent ramène à la liste des offres) ; (d) fait défiler #contact en vue (scrollIntoView({ block: 'start', behavior }), behavior = 'auto' en mouvement réduit, 'smooth' sinon ; le décalage d'en-tête est pris en charge par scroll-margin-top) ; (e) à la fin du défilement (événement scrollend quand il est disponible, sinon après 700 ms), place le focus sur la racine #contact (tabIndex={-1}, focus({ preventScroll: true })), sans ouvrir le clavier virtuel ni cibler un champ ;
  3. émet l'événement de mesure (6.5.9).

Côté formulaire (contrat à respecter par 6.9) : au montage, il lit d'abord takePendingPrefill(), puis, une seule fois, parsePrefill(window.location.search, …) ; si le paramètre offre est présent et valide, il pré-sélectionne le champ « offre » puis retire le paramètre de la barre d'adresse (history.replaceState(null, '', location.pathname + location.hash)). Il s'abonne à contact:prefill et met à jour les champs « offre » et « Votre situation (facultatif) » à chaque événement (une demande explicite via une offre ou via la boussole remplace une valeur déjà choisie ; au montage seul un champ intact est pré-rempli). Le champ « message » n'est jamais prérempli. Une valeur d'offre inconnue est ignorée sans message. Il annonce la valeur choisie dans une région role="status" (« Formulaire prérempli avec l'offre : {title}. »). L'adresse n'est pas mise à jour quand le visiteur change ensuite le champ à la main. La mesure d'audience ne reçoit ni le paramètre offre ni l'ancre (data-exclude-search et data-exclude-hash, §9.8). L'adresse canonique de la page reste la racine du site sans paramètre (voir §11).

Détail de l'offre (<details>). Clic ou Entrée/Espace sur le <summary> ouvre/ferme. Pas de JavaScript. Le résumé a une hauteur ≥ 44 px.

Lien d'orientation. <a href="#orientation"> (défilement natif).

Ordre de tabulation (par offre) : résumé de détail → bouton WhatsApp → lien formulaire ; puis offre suivante ; puis lien d'orientation.

6.5.7 Accessibilité #

  • Liste : <ol> (l'ordre d'affichage est significatif) ; chaque offre est un <li> contenant un <article aria-labelledby="offre-<id>-titre"> ; titre <h3 id="offre-<id>-titre">.
  • Les numéros « 01 »…« 04 » sont aria-hidden. Le badge de l'offre mise en avant est du texte réel (« Pour faire le point »), pas une icône.
  • Noms accessibles des liens : le texte visible est en tête, un complément masqué précise l'offre (« Écrire sur WhatsApp à propos de « Séance de clarification ciblée » (ouvre WhatsApp dans un nouvel onglet) » ; « Écrire via le formulaire pour « Séance de clarification ciblée » »). Le nom accessible commence par le texte visible (WCAG 2.5.3).
  • <details>/<summary> natifs : le rôle, l'état d'expansion et le clavier sont gérés par le navigateur. Le complément masqué du résumé nomme l'offre.
  • Prix : « Tarif » est un texte réel visible, lié au montant dans le même bloc de lecture ; « Sur devis » est lu tel quel. Le montant ne s'appuie pas sur la couleur seule.
  • Contrastes : ink-900/ink-600 sur ivory-100 ≥ 7:1 ; ivory-50/ivory-100 sur forest-900 ≥ 12:1 ; bouton WhatsApp (ivory-50 sur forest-800 ≥ 9:1 ; forest-950 sur champagne-400 ≥ 8:1) ; la barre de survol et les filets sont décoratifs.
  • Cibles : boutons 48 px (mobile) / 44 px (desktop) au minimum ; liens secondaires zone de 44 px de haut.
  • Mention de TVA : texte réel, pas image ; taille ≥ 14 px.
  • Un lien qui ouvre un nouvel onglet l'annonce dans son nom accessible.

6.5.8 Mouvement #

Références dans §8 : révélation de l'en-tête de section, apparition du panneau mis en avant puis des lignes avec décalage stagger de cartes (0,1 s), tracé des filets, barre d'accent au survol (échelle verticale), micro-interactions des boutons (glissement de l'icône, scale à l'appui, par les classes .motion-arrow et .motion-press), rotation du chevron. Discrétion exigée : pas de rebond, pas d'inclinaison 3D, pas d'ombre animée, pas de soulèvement des lignes. Propriétés animées : transform, opacity. Chaque élément se révèle une seule fois.

6.5.9 Événements de mesure #

Élément Événement
Bouton « Écrire sur WhatsApp » d'une offre cta_click { cta: 'offer_whatsapp', offer_id: <id> }
Lien « Écrire via le formulaire » cta_click { cta: 'offer_form', offer_id: <id> } (émis au clic simple prévenu et aussi au clic avec modificateur)
Résumé de détail, lien d'orientation aucun

Le suivi n'empêche jamais la navigation (voir §9.8). L'identifiant d'offre est la seule propriété ; aucune donnée personnelle n'est envoyée.

6.5.10 Cas limites #

Cas Comportement
Aucune offre (offers: []) Le build échoue (schéma min(1)) ; il n'existe donc pas d'état vide à l'exécution
Deux offres featured Build en échec avec le message « une seule offre peut être mise en avant »
Aucune offre featured Disposition even
Offre featured qui n'est pas la première par order Rendue en premier dans le DOM (disposition featured) ; les numéros suivent l'ordre d'affichage
Prix fixed à 1 000 € ou plus Format 1 000 €, jamais coupé (whitespace-nowrap)
Titre très long (80 caractères) Passe sur 3 lignes maximum sans déborder ; le prix reste visible (bascule sous le titre sous 768 px)
details vide Le <details> n'est pas rendu
duration absent Seul format (« À distance ») est affiché
whatsappMessage avec guillemets, apostrophes ou emoji Encodé par encodeURIComponent ; testé unitairement (le schéma de texte refuse les retours à la ligne)
URL WhatsApp > 1 900 caractères Build en échec (garde du schéma)
?offre= inconnu à l'arrivée Ignoré ; formulaire sur « Je ne sais pas encore » ; le paramètre est retiré de la barre d'adresse
?offre=accompagnement-personnalise Champ pré-sélectionné ; l'offre sur devis est valide ; le paramètre est retiré de la barre d'adresse
Clic avec Ctrl/Cmd ou clic du milieu sur le lien formulaire Navigation native vers /?offre=<id>#contact dans un nouvel onglet ; le formulaire s'y pré-remplit puis retire le paramètre
Clic répété sur le lien formulaire de deux offres différentes Le champ est mis à jour à chaque clic
Retour arrière après un préremplissage Retour à la position de la liste des offres (entrée d'historique précédente) ; le champ du formulaire reste inchangé
Mouvement réduit Défilement instantané
legal.vatMention très long (300 caractères) Le paragraphe passe à la ligne ; largeur max 48 rem
Ajout d'une 5ᵉ offre La disposition s'adapte : lignes supplémentaires à droite du panneau ; l'énumération du formulaire est mise à jour automatiquement
Fournisseur de réservation différent (futur Cal.com) getBookingAction renvoie un autre href/label ; aucun changement dans cette section

6.5.11 Critères d'acceptation #

ID Critère
AC-6.5-01 La section liste exactement les offres de offers.ts, dans l'ordre d'affichage défini en 6.5.2 ; avec la configuration de lancement : Séance (300 €), Lecture globale (150 €), Suivi (1 000 €), Accompagnement personnalisé (Sur devis), chacune avec le libellé visible « Tarif » et le format « À distance ».
AC-6.5-02 À 1440 px, l'offre mise en avant est un panneau sombre (OfferItem variante panel) en colonnes 1 → 6 occupant la hauteur de la liste ; les trois autres offres sont des lignes (variante row) en colonnes 7 → 12, séparées par des filets, sans fond, bordure ni ombre ; leurs hauteurs ne sont pas égalisées.
AC-6.5-03 À 768 px et 390 px, toutes les offres sont en colonne unique ; le panneau mis en avant est en premier ; aucun défilement horizontal à 320 px.
AC-6.5-04 Avec zéro offre featured, la section utilise la disposition even (2 colonnes à 1440 px) sans panneau sombre ; le test unitaire de getOffersLayout couvre 0, 1 et 2 offres featured et le cas d'une seule offre.
AC-6.5-05 formatPrice produit 300 €, 150 €, 1 000 € (avec espaces insécables du format fr-FR) et « Sur devis » ; test unitaire comparant à la sortie de Intl.NumberFormat('fr-FR', …).
AC-6.5-06 Chaque bouton WhatsApp a pour href la valeur retournée par getBookingAction({ offerId }), avec target="_blank" et rel="noopener noreferrer", pour libellé visible « Écrire sur WhatsApp » ; le texte ?text= décodé est égal au whatsappMessage de l'offre ; le texte masqué « (ouvre WhatsApp dans un nouvel onglet) » est présent.
AC-6.5-07 Aucun composant de la section ne contient la chaîne wa.me et aucun fichier de la section n'importe lib/booking/index.ts côté client ; l'URL est construite uniquement par getBookingAction, appelé côté serveur.
AC-6.5-08 Clic simple sur « Écrire via le formulaire » d'une offre : pas de rechargement (le marqueur window.__nav posé avant le clic persiste) ; l'adresse devient /#contact (aucun paramètre ajouté) ; #contact est visible sous l'en-tête ; le champ « offre » du formulaire vaut <id> ; le focus est sur la racine #contact ; le champ « message » reste vide.
AC-6.5-09 Chargement direct de /?offre=lecture-globale#contact : le champ « offre » est pré-sélectionné sur « Lecture globale de l'entrepreneur et de l'entreprise » et l'adresse est ensuite réécrite en /#contact sans paramètre ; avec ?offre=xyz : champ sur « Je ne sais pas encore », sans message d'erreur, paramètre retiré.
AC-6.5-10 Clic avec Ctrl/Cmd sur le lien formulaire ne déclenche pas preventDefault (navigation native).
AC-6.5-11 Le bouton Précédent du navigateur, après un préremplissage, ramène à la position de la liste des offres sur / (sans paramètre).
AC-6.5-12 Avec prefers-reduced-motion: reduce, le défilement vers #contact est instantané et aucune révélation n'utilise de translation.
AC-6.5-13 Les <details> : celui de l'offre mise en avant est ouvert au rendu serveur, les autres fermés ; Entrée/Espace sur un résumé les bascule sans JavaScript ; le libellé change entre « Voir le détail » et « Masquer le détail ».
AC-6.5-14 Chaque offre est un <article> nommé par son <h3> (offre-<id>-titre) ; les noms accessibles des liens d'une offre contiennent le titre de l'offre et commencent par leur texte visible.
AC-6.5-15 Ordre de tabulation : par offre, résumé de détail, bouton WhatsApp, lien formulaire ; aucun autre arrêt dans la section hormis le lien d'orientation final.
AC-6.5-16 Contraste de tous les textes ≥ 4,5:1, boutons compris, sur les fonds ivory-100 et forest-900 ; cibles ≥ 44 × 44 px.
AC-6.5-17 cta_click est émis avec offer_whatsapp ou offer_form et le bon offer_id quand l'analytique est activée ; aucun appel quand ANALYTICS_PROVIDER=none ; aucune donnée autre que cta et offer_id n'est transmise.
AC-6.5-18 La ligne de TVA affiche exactement legal.vatMention ; APP_ENV=production avec une valeur vide fait échouer pnpm build ; en développement, le repère data-dev-placeholder est visible.
AC-6.5-19 Le build échoue si deux offres ont le même id ou le même order, si deux offres sont featured, si un amountEur n'est pas un entier positif, ou si offers est vide.
AC-6.5-20 Ajouter une cinquième offre dans offers.ts la fait apparaître dans la section et comme option du champ « offre » du formulaire sans autre modification de code.
AC-6.5-21 Aucun CLS lié à la section (≤ 0,01) ; aucune superposition de texte aux largeurs 320, 375, 768, 1024, 1440 et 1920 px, avec le titre le plus long (80 caractères) et le prix « 1 000 € ».
AC-6.5-22 Sans JavaScript, les liens WhatsApp, les détails natifs et le lien formulaire (navigation complète vers /?offre=<id>#contact) fonctionnent.
AC-6.5-23 Aucune violation axe sur la section, détails ouverts et fermés.
AC-6.5-24 Le padding-block calculé de la section vaut celui du jeton --spacing-section (§5.6.1) ; le <h2> a l'identifiant accompagnements-titre ; aucune offre ne porte de classe ou de style de carte (bordure, ombre, hauteur égalisée) hors du panneau mis en avant.

6.6 Boussole d'orientation (F) #

6.6.1 Objectif #

Section #orientation, fond clair. Mini-module qui aide le visiteur à se situer : il choisit une des quatre situations (décision bloquée, fatigue, repositionnement, transition) et obtient (1) une piste de réflexion, (2) deux à trois questions à se poser, (3) l'accompagnement qui pourrait correspondre, (4) deux façons de contacter Malka.

Garde-fous non négociables :

  • Aucun diagnostic automatique, aucune promesse de résultat : l'avertissement global est toujours visible, avant comme après le choix.
  • Aucun score, aucune pondération, aucune « réponse unique » calculée : le résultat est une table de correspondance statique situation → offre définie dans orientation.ts.
  • Aucune persistance : pas de localStorage, sessionStorage, cookie, ni paramètre d'URL pour mémoriser le choix. L'état vit uniquement dans un useState du composant. La situation n'apparaît dans aucune URL (ni requête, ni fragment).
  • Aucune mesure : le choix de situation n'est jamais envoyé à l'outil d'analytics (voir §6.6.9).
  • La situation n'est transmise au serveur que si le visiteur envoie lui-même le formulaire de contact : le bouton « Écrire via le formulaire » la dépose, par le canal en mémoire requestContactPrefill (§6.5.4), dans le champ visible et facultatif « Votre situation » (§6.9.6), que le visiteur peut modifier ou vider. Le champ « message » n'est jamais prérempli.

6.6.2 Mise en page par breakpoint #

Conteneur : contenu 1280 px max, gouttières 24 / 40 / 64 px (voir §5). Padding vertical de section : token --spacing-section (py-section, §5.6.1). scroll-margin-top = hauteur de l'en-tête + 16 px (voir §4.2).

Desktop (≥ 1024 px) — grille 12 colonnes, deux zones ; les quatre choix sont en grille 2 × 2 :

┌──────────────────────────────────────────────────────────────────────────┐
│ ORIENTATION (eyebrow)                                                    │
│ Titre H2 (serif, 2 lignes max)                       Intro (cols 8–12)   │
├───────────────────────────────┬──────────────────────────────────────────┤
│ cols 1–5                      │ cols 6–12                                │
│ Légende du groupe (legend)    │ ┌──────────────────────────────────────┐ │
│ ┌─────────────┐ ┌───────────┐ │ │ PANNEAU RÉSULTAT                     │ │
│ │ ◉ Décision  │ │ ○ Fatigue │ │ │ Piste de réflexion (H3 + texte)      │ │
│ │   bloquée   │ │           │ │ │ Questions à vous poser (2–3)         │ │
│ └─────────────┘ └───────────┘ │ │ ┌──────────────────────────────────┐ │ │
│ ┌─────────────┐ ┌───────────┐ │ │ │ Carte offre suggérée             │ │ │
│ │ ○ Reposition│ │ ○ Transi- │ │ │ └──────────────────────────────────┘ │ │
│ │   nement    │ │   tion    │ │ │ [Écrire sur WhatsApp]                │ │
│ └─────────────┘ └───────────┘ │ │ [Écrire via le formulaire]  Réinit.  │ │
│                               │ └──────────────────────────────────────┘ │
├───────────────────────────────┴──────────────────────────────────────────┤
│ ⓘ Avertissement global                                (toujours visible) │
└──────────────────────────────────────────────────────────────────────────┘

Tablette (768–1023 px) : en-tête empilé ; chips en grille 2 × 2 (largeur 100 % du conteneur, hauteur 56 px) ; panneau résultat dessous, pleine largeur ; avertissement en bas.

Mobile (< 768 px) : une colonne ; chips empilés, pleine largeur, hauteur 56 px, espacement vertical 12 px ; panneau résultat dessous ; boutons CTA empilés pleine largeur (hauteur 48 px) ; le bouton de réinitialisation centré sous les boutons ; avertissement en bas. Largeur minimale supportée : 320 px (aucun défilement horizontal, texte des chips autorisé à passer sur 2 lignes).

Le panneau résultat occupe en permanence l'espace nécessaire à son état vide (min-height 280 px mobile, 320 px tablette, 440 px desktop) afin que le choix initial ne déplace pas le contenu situé dessous.

6.6.3 Source de contenu #

  • Données : src/content/fr/orientation.ts (schéma orientationSchema, §9.3.3) et src/content/fr/offers.ts pour la carte d'offre. Les textes sont en §7.8 ; ce document n'en reproduit que les clés.
  • Clés de texte d'interface consommées (orientation.*) :
Clé Usage
eyebrow, title, intro en-tête de section
groupLabel légende visible du groupe de choix (obligatoire)
emptyState texte de l'état vide du panneau
resultHeadings.reflection, resultHeadings.questions, resultHeadings.offer titres des trois blocs du panneau résultat
globalDisclaimer avertissement global, toujours affiché
resetLabel bouton de réinitialisation
ctaLabels.whatsapp (« Écrire sur WhatsApp »), ctaLabels.whatsappNewTabSuffix, ctaLabels.form (« Écrire via le formulaire ») les deux CTA du panneau
liveRegion.situationSelected ({label}), liveRegion.selectionReset annonces de lecteur d'écran

Les clés emptyState et resultHeadings font partie de orientationSchema (§9.3.3). Aucune chaîne destinée au visiteur n'est écrite dans les composants.

  • Forme de lecture exigée par le composant (voir situationSchema, §9.3.3) :
// Type dérivé du schéma (les schémas sont propriétaires des types) : aucune redéfinition manuelle.
type OrientationSituation = Content['orientation']['situations'][number];
// Forme résultante (tous les champs sont obligatoires) :
//   id: 'decision-bloquee' | 'fatigue' | 'repositionnement' | 'transition';
//   label: string;                  // texte du chip (≤ 28 caractères)
//   prompt: string;                 // piste de réflexion, 2 à 4 phrases
//   questions: string[];            // 2 à 3 questions d'auto-réflexion
//   suggestedOfferId: OfferId;      // doit exister dans offers.ts (build échoue sinon)
//   suggestedOfferSentence: string; // phrase d'introduction de la carte d'offre suggérée
//   disclaimer: string;             // avertissement propre à la situation, affiché sous l'avertissement global
//   whatsappMessage: string;        // message prérempli WhatsApp propre à la situation
  • whatsappMessage : 20 à 300 caractères, commence par « Bonjour Malka », nomme la situation par son label et ne contient aucun accolade non résolue. Il ne contient aucune donnée personnelle (le visiteur n'en saisit pas ici). Un message par situation est obligatoire (le schéma §9.3 refuse un tableau où l'une des quatre situations en est dépourvue).
  • Correspondance par défaut (inchangée sauf décision de Malka) : decision-bloquee → seance-clarification ; fatigue → lecture-globale ; repositionnement → lecture-globale ; transition → suivi-transition.
  • Avertissement global (toujours affiché) : clé globalDisclaimer ; son texte (§7.8) indique qu'il s'agit d'une piste de réflexion, ni d'un diagnostic ni d'une promesse de résultat, et ne doit jamais être retiré ni édulcoré.
  • Les liens WhatsApp sont calculés côté serveur : OrientationSection appelle getBookingAction({ situationId }) pour chacune des quatre situations (module src/lib/booking/, serveur uniquement, voir §9) et transmet à l'îlot actions: Record<SituationId, { href: string; label: string }>. Le composant client n'importe jamais lib/booking/index.ts et ne construit jamais l'URL lui-même. La fonction résout le whatsappMessage de la situation, l'encode avec encodeURIComponent et renvoie https://wa.me/33667295179?text=….

6.6.4 Arbre de composants et props #

<OrientationSection>                        src/components/sections/orientation/orientation-section.tsx  (server)
│                                           <section id="orientation" aria-labelledby="orientation-titre" tabindex="-1">
├─ <SectionHeader eyebrow title intro />    (partagé, voir §5)
├─ <OrientationStaticList situations offers actions />   (server ; masqué, affiché seulement sous @media (scripting: none))
└─ <OrientationCompass content offers actions />         src/components/sections/orientation/orientation-compass.tsx  (client, rendu complet côté serveur)
   ├─ <fieldset role="radiogroup" aria-describedby="orientation-disclaimer">
   │  ├─ <legend> groupLabel
   │  └─ <ChoiceChip> × 4                    (radios natifs)
   ├─ <StableSwap axis="y" distance="xs">    (§8 ; état vide ⇄ résultat)
   │  ├─ <OrientationEmptyState />            (si aucune sélection)
   │  └─ <OrientationResult situation offer actions onReset />
   │     ├─ <ReflectionBlock prompt questions />
   │     ├─ <SuggestedOfferCard offer />
   │     ├─ <OrientationActions … />
   │     └─ <button> resetLabel
   ├─ <p id="orientation-disclaimer">         (global, toujours rendu)
   └─ <div role="status" aria-live="polite" aria-atomic="true" class="sr-only" />   (annonces)

Les chips sont rendus dans le HTML serveur (non masqués) : le module est lisible dès le premier rendu. OrientationStaticList est dans le HTML serveur mais display: none par défaut ; la règle @media (scripting: none) de globals.css l'affiche et masque le bloc interactif.

interface OrientationSectionProps {
  content: OrientationContent;           // voir §9.3.3 ; section serveur, lit getContent
  offers: Offer[];                       // pour retrouver l'offre suggérée
}

interface OrientationCompassProps {
  content: OrientationContent;           // sérialisé par le serveur ; l'îlot n'importe aucun contenu
  offers: Pick<Offer, 'id' | 'title' | 'shortDescription' | 'price' | 'format'>[];
  actions: Record<OrientationSituation['id'], { href: string; label: string }>;  // liens WhatsApp calculés côté serveur
}

interface ChoiceChipProps {
  id: string;                            // ex. 'orientation-chip-fatigue'
  name: string;                          // 'orientation-situation' (groupe radio natif)
  value: string;                         // situation.id
  label: string;
  checked: boolean;
  onChange: (value: string) => void;
  describedBy?: string;                  // id de l'avertissement global
}

interface OrientationResultProps {
  situation: OrientationSituation;
  offer: Offer | undefined;              // undefined → carte omise et offerId non transmis
  globalDisclaimer: string;
  onReset: () => void;
}

interface OrientationActionsProps {
  situationId: OrientationSituation['id'];
  offerId?: OfferId;
  whatsapp: { href: string; label: string };   // issu de `actions`
  formLabel: string;                           // ctaLabels.form
}

Fichiers annexes : ChoiceChip, OrientationResult et les blocs du panneau sont définis dans orientation-compass.tsx ; OrientationStaticList dans orientation-section.tsx. SuggestedOfferCard n'est pas la carte de §6.5 : c'est une version compacte (titre, prix, description courte) qui réutilise le formateur de prix de §6.5.

Hook d'état (interne) :

const [selectedId, setSelectedId] = useState<OrientationSituation['id'] | null>(null);
const [announcement, setAnnouncement] = useState('');   // texte de la région live
const lastInputWasPointer = useRef(false);               // vrai si le dernier choix vient du pointeur ou du toucher

6.6.5 États #

État Condition Rendu
Avant hydratation HTML serveur 4 chips non cochés rendus, panneau en état vide, avertissement global visible ; les choix prennent effet à l'hydratation
Sans JavaScript @media (scripting: none) La liste statique des quatre situations remplace le sélecteur (voir §6.6.10)
Initial selectedId === null 4 chips non cochés, panneau en état vide (emptyState), avertissement global visible, bouton de réinitialisation absent
Sélectionné selectedId !== null Chip coché (fond forest-800, texte ivory-50, icône Check visible), panneau résultat, bouton de réinitialisation visible
Offre introuvable suggestedOfferId sans offre (impossible après build, défensif) Carte d'offre omise, offerId non transmis à requestContactPrefill, reste du panneau inchangé
Chargement / erreur / succès — Sans objet : module 100 % local, aucun appel réseau
Désactivé — Sans objet : aucun chip n'est jamais désactivé
Mouvement réduit prefers-reduced-motion: reduce Bascule de panneau en fondu instantané (§8), défilement automatique remplacé par saut sans animation

6.6.6 Interactions #

Souris

  • Clic sur un chip (zone entière du label) : coche le chip, affiche le panneau.
  • Clic sur le chip déjà coché : sans effet (un radio natif ne se décoche pas) ; la remise à zéro passe par le bouton de réinitialisation.
  • Survol d'un chip non coché : fond ivory-100, bordure sage-600. L'appui sur un chip utilise la classe .motion-press (échelle 0,98, neutralisée en mouvement réduit, §8).

Tactile

  • Tap sur un chip : même effet que le clic. Zone tactile ≥ 56 px de haut sur toute la largeur. Pas de geste exclusif (aucun glisser).

Clavier (radios natifs <input type="radio" name="orientation-situation"> à l'intérieur du fieldset role="radiogroup")

  • Tab : entre dans le groupe sur le chip coché, ou sur le premier si aucun n'est coché ; un second Tab sort du groupe (comportement natif, un seul arrêt de tabulation).
  • ↓ / → : sélectionne le chip suivant (retour au premier après le dernier) ; ↑ / ← : précédent. La sélection suit le focus (comportement natif des radios).
  • Espace : coche le chip focalisé s'il ne l'est pas.
  • Le focus n'est jamais déplacé automatiquement vers le panneau résultat (l'utilisateur peut continuer à parcourir les choix). Le panneau suit le groupe dans l'ordre du DOM : Tab depuis le groupe atteint directement ses liens et boutons.

Défilement automatique : uniquement après une sélection au pointeur ou au toucher (lastInputWasPointer.current === true, posé par onPointerDown sur le chip), sur mobile et tablette (< 1024 px), si le haut du panneau est hors écran (rect.top > innerHeight - 96) : panel.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' }). Jamais au clavier (les flèches parcourent les choix sans que la page bouge), jamais sur desktop.

« Écrire sur WhatsApp » (ctaLabels.whatsapp) : lien <a target="_blank" rel="noopener noreferrer"> vers l'href de actions[situation.id] (calculé côté serveur) ; texte masqué ctaLabels.whatsappNewTabSuffix.

« Écrire via le formulaire » (ctaLabels.form) : <button type="button"> qui appelle requestContactPrefill({ offerId: suggestedOfferId, situationId }) (§6.5.4). Cet appel dépose les deux valeurs en mémoire, émet contact:prefill, fait défiler #contact en vue et place le focus sur sa racine ; il n'écrit aucune URL (ni pushState, ni paramètre, ni fragment pour la situation). Le formulaire (§6.9.7) présélectionne alors l'accompagnement et la situation dans ses deux listes visibles ; le champ « message » reste vide.

Bouton de réinitialisation (resetLabel) : setSelectedId(null), vide la région live puis y écrit liveRegion.selectionReset, décoche tous les radios, place le focus sur le premier chip (focus({ preventScroll: true })). Aucun autre effet de bord.

Annonces (région live) : à chaque changement de sélection, announcement est mis à jour après un debounce de 300 ms (évite l'empilement lors d'un parcours rapide aux flèches) avec liveRegion.situationSelected ({label} = libellé de la situation).

6.6.7 Accessibilité #

  • Groupe : <fieldset role="radiogroup" aria-describedby="orientation-disclaimer"> avec <legend> visible portant groupLabel.
  • Chaque chip : <label> englobant un <input type="radio" class="sr-only peer"> et une pastille ; état coché porté par :checked (jamais par la couleur seule : icône Check + changement d'épaisseur de bordure).
  • Panneau résultat : <div id="orientation-result" role="region" aria-labelledby="orientation-result-title">. Il n'est pas aria-live (l'annonce passe par la région dédiée, pour éviter la double lecture).
  • Région live : <div role="status" aria-live="polite" aria-atomic="true" class="sr-only">, présente dans le DOM dès le premier rendu.
  • Avertissement global : <p id="orientation-disclaimer"> avec icône Info (aria-hidden="true"), contraste ≥ 4,5:1 (ink-600 sur ivory), taille ≥ 14 px.
  • Cible tactile des chips ≥ 44 × 44 (primaire, 56 px de hauteur) ; boutons CTA 48 px de hauteur.
  • Anneau de focus : turquoise-600 2 px + décalage 2 px sur fond clair (voir §5) ; visible sur le chip via peer-focus-visible.
  • Titre du panneau : h3 (id="orientation-result-title", texte resultHeadings.reflection) ; blocs suivants en h4 ; ordre des titres de la section : h2 (titre) → h3 (panneau) → h4.
  • Contraste : texte ivory-50 sur forest-800 pour le chip coché ≥ 10:1 ; bordure des chips non cochés (sage-600 sur ivory) ≥ 3:1.
  • Aucun contenu n'est transmis par le seul survol.

6.6.8 Motion (voir §8) #

  • Entrée de section : révélation au scroll (une seule fois) de l'en-tête, puis des chips en cascade (stagger cartes 0,1 s) ; durée base 0,5 s, easing easeClarte.
  • Changement de situation : le panneau résultat utilise StableSwap (axe y, distance xs, §8) — seul usage de ce composant avec le résultat de la boussole : sortie en opacité fast 0,25 s, entrée opacité 0→1 + translation Y 8→0 px, base 0,5 s. Aucune animation de hauteur : le panneau a une hauteur réservée.
  • Micro-interaction du chip : appui .motion-press (échelle 0,98) ; aucune échelle au survol.
  • Mouvement réduit : tout est remplacé par un fondu d'opacité instantané (≤ 0,15 s) ; pas de scale, pas de translation, pas de défilement lissé.

6.6.9 Analytics (voir §9.8) #

  • Aucun événement à la sélection d'une situation, à la réinitialisation ni à l'affichage du résultat.
  • Les deux CTA du panneau émettent cta_click avec cta: 'orientation_contact', sans offer_id ni identifiant de situation (le couple situation → offre est quasi déterministe : il révélerait le choix). Les deux boutons partagent cette valeur ; le canal (WhatsApp ou formulaire) n'est pas distingué.
  • track() est sans effet quand ANALYTICS_PROVIDER=none.

6.6.10 Cas limites #

  1. Sans JavaScript (@media (scripting: none)) : le bloc interactif est masqué et OrientationStaticList affiche les quatre situations sous forme de liste statique (par situation : libellé en h3, piste de réflexion, questions, nom de l'offre suggérée avec lien vers #accompagnements, puis un lien mailto: et un lien WhatsApp génériques calculés côté serveur par getBookingAction({})), suivie de l'avertissement global. Aucun <noscript> ni style en ligne.
  2. suggestedOfferId inconnu : le build échoue (validation croisée §9.3) ; défensivement au runtime, la carte est omise (voir §6.6.5).
  3. Retour arrière du navigateur, rechargement, changement de page puis retour : la sélection est perdue (comportement voulu, aucune persistance).
  4. Clic sur « Écrire via le formulaire » : la sélection reste affichée ; le visiteur peut remonter dans la page et la retrouver telle quelle ; l'adresse de la page n'a pas changé.
  5. Libellé de chip long (> 28 caractères) : refusé par le schéma ; au besoin le texte passe sur deux lignes sans tronquer.
  6. Parcours rapide aux flèches : le panneau suit sans file d'attente d'animations, la région live n'annonce que la dernière sélection (debounce 300 ms) et la page ne défile pas.
  7. Zoom 200 % et texte agrandi : aucun texte coupé, les chips se replient sur une colonne, le panneau passe sous le groupe.
  8. Mode contraste élevé (forced-colors: active) : la coche Check et la bordure 2px (couleur CanvasText/Highlight) distinguent l'état sélectionné.
  9. Message WhatsApp d'une situation absent : refusé par le schéma ; défensivement, le serveur retombe sur le message générique de l'offre suggérée avant de calculer l'href.
  10. Les trois sections de réflexion (piste, questions, offre) ne contiennent jamais de promesse de résultat ni de formulation médicale ; contrôle éditorial dans §7.

6.6.11 Critères d'acceptation #

  • AC-6.6-01 — La section #orientation contient exactement un fieldset role="radiogroup" avec une legend visible et quatre radios natifs rendus dans le HTML serveur, nommés d'après orientation.ts (ids decision-bloquee, fatigue, repositionnement, transition).
  • AC-6.6-02 — Au chargement, aucun chip n'est coché et le panneau affiche l'état vide ; l'avertissement global est visible.
  • AC-6.6-03 — Sélectionner un chip affiche, dans le panneau, la piste de réflexion, 2 ou 3 questions, la carte de l'offre définie par suggestedOfferId, les deux CTA (« Écrire sur WhatsApp » et « Écrire via le formulaire ») et le bouton de réinitialisation.
  • AC-6.6-04 — L'avertissement global reste visible dans les états initial et sélectionné, à 320, 768 et 1440 px.
  • AC-6.6-05 — Au clavier, Tab n'effectue qu'un arrêt dans le groupe ; les flèches changent la sélection ; Espace coche ; le focus n'est pas déplacé vers le panneau ; la page ne défile pas lors des flèches.
  • AC-6.6-06 — Le lien WhatsApp contient wa.me/33667295179?text= suivi du whatsappMessage de la situation encodé ; il s'ouvre dans un nouvel onglet avec rel="noopener noreferrer" ; le bundle client de l'îlot n'importe pas lib/booking/index.ts.
  • AC-6.6-07 — Le bouton « Écrire via le formulaire » appelle requestContactPrefill({ offerId, situationId }) : #contact reçoit le focus, les listes « accompagnement » et « situation » du formulaire (§6.9) sont présélectionnées, le champ « message » est vide et location.href est identique avant et après le clic (test E2E avec §6.9).
  • AC-6.6-08 — Le bouton de réinitialisation vide la sélection, replace le focus sur le premier chip et annonce liveRegion.selectionReset.
  • AC-6.6-09 — Après sélection, la région role="status" contient le libellé de la situation (vérifié après 300 ms).
  • AC-6.6-10 — Aucune écriture dans localStorage, sessionStorage ni cookie, aucune requête réseau sortante pendant la sélection (test Playwright avec interception) ; aucune URL, requête ou fragment ne contient l'identifiant de situation.
  • AC-6.6-11 — Avec ANALYTICS_PROVIDER=umami, la sélection d'une situation n'émet aucun événement ; les clics sur les CTA émettent cta_click avec cta: 'orientation_contact' sans offer_id.
  • AC-6.6-12 — Rendu avec @media (scripting: none) : la liste statique des quatre situations est lisible et aucun contrôle inopérant n'est visible.
  • AC-6.6-13 — prefers-reduced-motion: reduce : changement de panneau sans translation ni scale, sans défilement lissé.
  • AC-6.6-14 — Axe (@axe-core/playwright) ne rapporte aucune violation sur la section dans les états initial et sélectionné.
  • AC-6.6-15 — Aucun défilement horizontal à 320 px ; hauteur des chips ≥ 56 px ; CTA ≥ 48 px ; à partir de 768 px les chips sont en grille 2 × 2.
  • AC-6.6-16 — Le défilement automatique du panneau n'a lieu qu'après une sélection au pointeur ou au toucher sur un viewport < 1024 px, jamais après une sélection au clavier.

6.7 À propos de Malka (G) #

6.7.1 Objectif #

Section #a-propos, fond clair. Elle donne un visage et une posture à l'accompagnement : une photographie réelle de Malka, une présentation personnelle rédigée par elle, sa posture professionnelle et son approche humaine. La section ne contient ni chiffres de confiance (ils appartiennent au hero, §6.2), ni liste de valeurs (la philosophie, §6.3, porte les principes), ni pictogrammes de piliers.

Règle de fond : aucun fait biographique n'est écrit par le développeur. Pas de parcours, de diplôme, de certification, d'année, de lieu d'études ni d'anecdote inventés. Le texte de présentation est fourni et validé par Malka ; la structure ci-dessous encadre ce qu'elle doit fournir, le contrôle de forme et la garde de build. Les formulations proposées dans §7 sont marquées « proposition à valider par Malka » et ne sont jamais publiées sans validation (voir §6.7.3).

6.7.2 Mise en page par breakpoint #

Desktop (≥ 1024 px) — 12 colonnes, portrait à gauche, texte à droite :

┌──────────────────────────────────────────────────────────────────────────┐
│ ┌───────────────────┐   À PROPOS (eyebrow)                               │
│ │                   │   Titre H2 serif                                   │
│ │                   │                                                    │
│ │   PORTRAIT 4:5    │   Paragraphe 1 (lead, taille +2)                   │
│ │   (cols 1–5)      │   Paragraphe 2                                     │
│ │                   │   Paragraphe 3 (optionnel)                         │
│ │                   │                                                    │
│ │                   │   ┌─ Posture professionnelle ─┬─ Approche humaine ┐│
│ └───────────────────┘   └───────────────────────────┴───────────────────┘│
└──────────────────────────────────────────────────────────────────────────┘
  • Portrait : colonnes 1–5, ratio fixe 4:5, align-self: start, position: sticky; top: calc(var(--header-h) + 24px) jusqu'à la fin de la colonne de texte (désactivé si la colonne de texte est plus courte que le portrait).
  • Texte : colonnes 7–12 ; posture et approche en deux blocs côte à côte (3 colonnes chacun) si l'espace le permet, sinon empilés.
  • Padding vertical de la section : token --spacing-section (py-section, §5.6.1).

Tablette (768–1023 px) : portrait au-dessus, largeur max 420 px, aligné à gauche ; texte pleine largeur dessous (mesure ≤ 68 caractères) ; posture et approche empilées.

Mobile (< 768 px) : portrait pleine largeur du conteneur (max 420 px), ratio 4:5 ; texte ; blocs empilés.

Décor : un cadre fin champagne-400 1 px décalé de 16 px derrière le portrait (desktop et tablette), purement décoratif (aria-hidden), contenu dans la marge de la gouttière : il ne déborde jamais du conteneur et ne recouvre aucun texte.

6.7.3 Source de contenu et créneaux fournis par Malka #

Fichier : src/content/fr/about.ts (schéma aboutSchema, §9.3.3 ; valeurs en §7.9).

interface AboutContent {
  eyebrow: string;
  title: string;                         // 1 phrase, ≤ 90 caractères
  portrait: ImageSlot;                   // créneau informatif (§9.3.2) : src, dimensions, alt, objectPosition? (défaut '50% 25%')
  bio: {
    paragraphs: string[];                // 0 à 4 éléments, voir règles ci-dessous
    devPlaceholder?: string;             // affiché à la place de la biographie quand `paragraphs` est vide (hors production)
    devNotice?: string;                  // bandeau affiché quand la biographie n'est pas encore validée (hors production)
  };
  posture: { title: string; body: string };    // posture professionnelle
  approach: { title: string; body: string };   // approche humaine
}

La section n'a ni values, ni chiffres de confiance, ni titre de chiffres ; le schéma ne comporte aucune de ces clés.

Règles de validation (Zod au build, puis gardes de production) :

Champ Règle
portrait Créneau informatif (§9.3.2). Le schéma accepte un SVG en développement (fichier public/images/dev-fallback/about-portrait.svg, forme abstraite sans visage). Les contrôles raster sont des gardes de production exécutées au build par scripts/check-assets.ts : fichier public/images/about/about-portrait.webp, largeur ≥ 1600 et hauteur ≥ 2000, rapport largeur/hauteur = 0,8 ± 0,01 (ratio 4:5), entrée au registre des licences docs/assets-licences.json, aucun chemin dev-fallback
portrait.alt 10–140 caractères ; ne commence pas par « Image de » ni « Photo de » ; ne contient aucune information biographique ; différent du texte alternatif du portrait du hero (texte en §7.17)
bio.paragraphs 0 à 4 éléments en développement et staging ; 2 à 4 en production (garde G-05) ; chacun 80–900 caractères ; total ≤ 2 200 caractères ; aucun ne contient de marqueur de texte provisoire (liste des marqueurs interdits définie dans la garde de §9.3, insensible à la casse)
Validation de la biographie Source unique : le registre content-validation.json, clé biography (garde G-05). Il n'existe aucun champ status ni validatedOn dans about.ts. En production, la clé biography doit être validée (validatedBy renseigné et validatedOn valide, §1.3.3) et paragraphs compter 2 à 4 éléments, sinon pnpm build échoue en nommant la clé biography. En développement et staging, une clé non validée affiche le bandeau discret bio.devNotice (role="note") au-dessus du bloc de présentation
posture.body, approach.body 60–400 caractères

Fichier du portrait : photographie réelle fournie par Malka, un seul fichier maître public/images/about/about-portrait.webp, ratio 4:5, ≥ 1600 × 2000 px, jamais générée ni modifiée par IA, visage non retouché au point d'altérer l'identité. Les formats AVIF/WebP responsives sont produits à la demande par next/image (voir §11) ; aucune variante n'est livrée. En développement, sans fichier fourni, about.ts référence public/images/dev-fallback/about-portrait.svg ; la garde de production le refuse.

6.7.4 Arbre de composants et props #

<AboutSection>                           src/components/sections/about/about-section.tsx (server)
│                                        <section id="a-propos" aria-labelledby="a-propos-titre" tabindex="-1">
├─ <SectionHeader eyebrow title />
├─ <AboutPortrait portrait />            next/image, cadre décoratif
├─ <DevNotice> (dev/staging, biographie non validée au registre)
├─ <BioText paragraphs />
└─ <AboutPillars posture approach />     2 × <PillarBlock> (titre + texte, sans pictogramme)

AboutPortrait, DevNotice, BioText, AboutPillars et PillarBlock sont des composants serveur définis dans about-section.tsx.

interface AboutSectionProps { content: AboutContent; showDevNotice: boolean }   // showDevNotice : calculé à la génération statique (phase de build) à partir du registre, jamais évalué à l'exécution du conteneur
interface AboutPortraitProps { portrait: AboutContent['portrait'] }
interface PillarBlockProps { title: string; body: string }

Image : <Image src width height alt sizes="(min-width:1024px) 40vw, (min-width:768px) 420px, 100vw" loading="lazy" className="aspect-[4/5] w-full object-cover" style={{ objectPosition }} /> avec placeholder="blur" lorsque le fichier est importé statiquement. Le conteneur porte aspect-ratio: 4 / 5 pour que l'espace soit réservé (CLS 0).

6.7.5 États #

État Condition Rendu
Normal contenu validé Portrait, texte, blocs
Image en chargement loading="lazy" Fond ivory-200 + flou (blur) dans le cadre 4:5, sans décalage
Image en erreur échec réseau next/image affiche le fond ivory-200 ; l'alt reste lu ; mise en page inchangée
Biographie non validée (dev/staging) clé biography non validée au registre Bandeau discret role="note" (bio.devNotice) ; en production : build en échec
Biographie vide (dev/staging) paragraphs vide bio.devPlaceholder dans un encart visuellement distinct ; en production : build en échec
Mouvement réduit prefers-reduced-motion Aucune translation ; fondu ≤ 0,2 s ou apparition directe ; pas de légère dérive du portrait

6.7.6 Interactions #

La section est non interactive (aucun lien sauf éventuels liens du texte : aucun prévu). Aucun comportement spécifique souris/tactile/clavier : la lecture se fait par défilement. Le portrait n'est ni cliquable ni ouvrable en plein écran.

6.7.7 Accessibilité #

  • <section id="a-propos" aria-labelledby="a-propos-titre"> ; h2 du titre ; blocs de posture et d'approche en h3.
  • alt du portrait : description factuelle et neutre (texte en §7.17), jamais décorative et distincte de l'alt du portrait du hero ; le cadre champagne est aria-hidden.
  • Mesure de lecture : 60–68 caractères ; interligne 1,6 ; paragraphe d'ouverture à 1,125× la taille de base.
  • Contraste texte ink-900/ink-600 sur ivory ≥ 7:1 / ≥ 5,5:1 ; textes champagne-600 uniquement ≥ 18 px ou en gras ≥ 14 px (voir §5).

6.7.8 Motion (voir §8) #

  • Portrait : révélation par clip-path (inset du bas vers le haut) slow 0,9 s, easeClarte, une fois, au passage dans le viewport ; dérive parallaxe désactivée (aucun parallaxe dans cette section).
  • Texte : révélation en cascade paragraphe par paragraphe (stagger lignes 0,08 s), base 0,5 s.
  • Mobile : pas de clip-path animé, fondu simple.

6.7.9 Analytics (voir §9.8) #

Aucun événement dans cette section.

6.7.10 Cas limites #

  1. Un seul paragraphe fourni : accepté en développement et staging, refusé en production (minimum 2) ; Malka doit rédiger au moins deux paragraphes courts.
  2. Paragraphe très long (> 900 caractères) : refusé ; la limite préserve la lisibilité sur mobile.
  3. Portrait au mauvais ratio ou trop petit : refusé au build de production par scripts/check-assets.ts (message : « portrait à propos : ratio 4:5 attendu » / dimensions minimales).
  4. Portrait manquant en production ou identique au hero : le fichier doit exister ; le doublon avec le portrait du hero n'est pas bloquant mais déconseillé (cadrage différent recommandé, alt différent exigé).
  5. Mot « accompagnement vibratoire et stratégique » : utilisable tel quel dans le texte ; aucun terme médical (« thérapie », « soigner », « guérir », « burn-out » comme diagnostic) n'est autorisé : lint éditorial listé dans §7.
  6. Texte très long dans la colonne desktop plus haut que le portrait : le portrait cesse de coller en bas de colonne (comportement sticky) sans chevauchement.
  7. Registre content-validation.json absent ou clé biography non validée : voir 6.7.3 (bandeau hors production, échec de build en production).

6.7.11 Critères d'acceptation #

  • AC-6.7-01 — La section #a-propos rend un portrait <img> de ratio 4:5 sans décalage de mise en page (CLS de la section ≤ 0,01).
  • AC-6.7-02 — Le texte de présentation affiche exactement les paragraphes de about.ts, dans l'ordre, sans ajout.
  • AC-6.7-03 — APP_ENV=production avec la clé biography du registre content-validation.json non validée (ou moins de 2 paragraphes) fait échouer pnpm build en nommant biography ; about.ts ne contient aucun champ status ni validatedOn.
  • AC-6.7-04 — APP_ENV=production avec un portrait référençant dev-fallback fait échouer le build.
  • AC-6.7-05 — La section ne rend ni chiffres de confiance, ni liste de valeurs, ni pictogramme de pilier ; elle contient uniquement le portrait, le titre, la biographie, la posture et l'approche.
  • AC-6.7-06 — Le portrait possède un alt non vide de 10 à 140 caractères, différent de celui du portrait du hero ; le cadre décoratif est aria-hidden.
  • AC-6.7-07 — Aucun défilement horizontal à 320 px ; en mobile le portrait précède le texte.
  • AC-6.7-08 — prefers-reduced-motion: reduce : aucun clip-path animé.
  • AC-6.7-09 — Aucun marqueur de texte provisoire (liste de §9.3) ne figure dans le HTML rendu de la section.
  • AC-6.7-10 — En développement avec paragraphs vide, bio.devPlaceholder est visible dans un encart distinct ; ce texte n'existe dans aucune page rendue avec APP_ENV=production.

6.8 Témoignages (H) #

6.8.1 Objectif #

Section #temoignages, fond clair, affichant des témoignages réels, vérifiés et autorisés. Elle n'existe que s'il y a au moins un témoignage publiable ; sinon la section et tous les liens qui y mènent (navigation §6.1, pied de page §6.10) disparaissent. Aucun témoignage inventé, aucune photo de client, aucun monogramme : texte seulement (citation, nom, contexte, accompagnement concerné).

6.8.2 Règle de publication (source unique) #

Fonction unique getPublishableTestimonials() dans src/lib/content/testimonials.ts, utilisée par la page, la navigation, le pied de page et les tests :

export function isPublishable(t: Testimonial): boolean {
  return t.published === true
    && t.consent.status === 'granted'
    && t.quote.trim().length > 0;
}
export function getPublishableTestimonials(all: Testimonial[]): Testimonial[] {
  return all.filter(isPublishable); // ordre du tableau conservé, aucun tri
}

Un témoignage est publiable si et seulement s'il est publié, si le consentement est accordé et si la citation n'est pas vide. Règles de schéma complémentaires (build, §9.3.3) : published: true exige consent.status === 'granted', consent.grantedOn (YYYY-MM-DD, pas dans le futur) et une quote non vide d'au plus 700 caractères (aucun minimum de longueur) ne contenant aucun marqueur de texte provisoire (liste de §9.3). authorName : 2–60 caractères. authorContext : ≤ 80 caractères. offerId, s'il est présent, doit exister dans offers.ts. consent.grantedOn et consent.channel ne sont jamais affichés.

Contenu livré : deux entrées stephanie (Stéphanie, contexte « Steff Beauté Plurielle ») et emilie (Émilie), published:false, consent.status:'pending', quote:''. Malka copie le texte mot pour mot depuis son site actuel après vérification, renseigne le consentement, puis passe published à true. Le développeur ne rédige, ne corrige, ne résume ni ne traduit aucune citation.

Conséquences d'un nombre de témoignages publiables N :

N Rendu
0 <TestimonialsSection> retourne null ; lien « Témoignages » retiré de la navigation (desktop et menu mobile) et du pied de page ; aucun id="temoignages" dans le DOM ; le lien de saut ou toute ancre #temoignages est absent ; aucune réserve d'espace
1 Citation statique unique, sans aucun contrôle de carrousel (voir §6.8.7)
≥ 2 Carrousel à une citation visible à la fois (voir §6.8.5 et §6.8.6)

La navigation reçoit ses liens par une fonction getNavLinks({ hasTestimonials }) ; aucune section ne code en dur la présence du lien. Le padding vertical de la section est le token --spacing-section (py-section, §5.6.1) : retirer la section ne crée ni espace double ni vide.

Clés de texte consommées (testimonials.*, schéma §9.3.3, valeurs en §7.10) : eyebrow, title, intro, regionAriaLabel, controls.previousLabel, controls.nextLabel, controls.paginationAriaLabel ({n}, {total}), slideLabel ({n}, {total}), offerTagLabel, roleDescriptions.carousel, roleDescriptions.slide, liveRegion.slideChanged ({n}, {total}, {authorName}), authorDisplay.withContext / withoutContext, verificationNote.

6.8.3 Mise en page par breakpoint #

Desktop (≥ 1024 px) :

┌──────────────────────────────────────────────────────────────────────────┐
│ TÉMOIGNAGES (eyebrow)                                                    │
│ Titre H2                                                                 │
│                                                                          │
│   “   (pictogramme Quote décoratif)                                       │
│   « Texte de la citation, serif, grande taille,                          │
│     mesure ≤ 38 caractères × ~2 colonnes »               (cols 2–10)     │
│                                                                          │
│       Stéphanie — Steff Beauté Plurielle                                 │
│       [ Séance de clarification ciblée ]       (étiquette d'offre)       │
│                                                                          │
│   [‹]    ● ○ ○     01 / 03    [›]                                        │
└──────────────────────────────────────────────────────────────────────────┘
  • Citation : colonnes 2–10, centrée sur la grille, alignée à gauche dans son bloc. Boutons et points sous la citation, alignés à gauche de la citation.
  • Hauteur du viewport du carrousel = hauteur de la plus haute citation (empilement en grille CSS, voir §6.8.5) : aucun saut de mise en page au changement.

Tablette (768–1023 px) : citation colonnes 1–12 avec gouttière 40 px ; contrôles dessous, centrés.

Mobile (< 768 px) : citation pleine largeur ; taille de police réduite (voir §6.8.8) ; boutons précédent/suivant aux deux extrémités de la ligne de contrôle, points au centre ; compteur masqué sous 380 px.

6.8.4 Arbre de composants et props #

<TestimonialsSection>                     src/components/sections/testimonials/testimonials-section.tsx (server)
│                                         <section id="temoignages" aria-labelledby="temoignages-titre" tabindex="-1">
├─ <SectionHeader eyebrow title />
└─ N === 1 ? <TestimonialCard testimonial />
           : <TestimonialsSlider testimonials offers copy />   src/components/sections/testimonials/testimonials-slider.tsx (client)
              └─ <section aria-roledescription={roleDescriptions.carousel} aria-label={regionAriaLabel}>
                 ├─ <div class="viewport">           (grille CSS : les N panneaux occupent la même cellule)
                 │  └─ <div role="group" aria-roledescription={roleDescriptions.slide} aria-label={slideLabel}> × N
                 │     └─ <TestimonialCard />
                 ├─ <CarouselControls>
                 │  ├─ <button> précédent
                 │  ├─ <div class="dots"> <button aria-current> × N
                 │  ├─ <span> compteur 01 / 03 (aria-hidden)
                 │  └─ <button> suivant
                 └─ <div class="sr-only" aria-live="off" | "polite" aria-atomic="true">   (annonces)
interface TestimonialsSectionProps {
  testimonials: Testimonial[];   // déjà filtrés par getPublishableTestimonials
  offers: Offer[];
  content: Content['testimonials'];   // §9.3.3
}
interface TestimonialCardProps {
  testimonial: Testimonial;
  offerTitle?: string;           // résolu depuis offers.ts
  authorDisplay: Content['testimonials']['authorDisplay'];
  offerTagLabel: string;
  headingLevel?: never;          // la carte n'a pas de titre propre
}
interface TestimonialsSliderProps {
  testimonials: Testimonial[];
  offers: Pick<Offer, 'id' | 'title'>[];
  copy: Pick<Content['testimonials'], 'regionAriaLabel' | 'controls' | 'liveRegion' | 'authorDisplay'>;
  initialIndex?: 0;              // toujours 0
}

TestimonialCard est un composant de présentation sans directive 'use client', défini dans testimonials-section.tsx et réutilisé par le carrousel. L'îlot ne reçoit que des props sérialisées et n'importe aucun contenu.

Structure de TestimonialCard :

<figure class="testimonial">
  <Quote aria-hidden="true" />                      <!-- pictogramme décoratif Lucide `Quote` (§5.8) -->
  <blockquote><p>« {quote} »</p></blockquote>      <!-- guillemets « » ajoutés par le composant, texte de la citation intact -->
  <figcaption>
    <span class="author">{authorDisplay interpolé}</span>
    <span class="offer-tag">{offerTitle}</span>     <!-- si présent, précédé du libellé masqué offerTagLabel -->
  </figcaption>
</figure>

Le composant ajoute les guillemets français autour de la citation avec des espaces insécables à l'intérieur (caractère défini en §5.5.6) ; la citation elle-même n'est jamais modifiée. Le nom et le contexte sont rendus par le gabarit authorDisplay.withContext ou withoutContext (aucun séparateur n'est ajouté par le CSS).

6.8.5 États et fonctionnement du carrousel #

État interne : const [index, setIndex] = useState(0), const [mounted, setMounted] = useState(false), const [interacted, setInteracted] = useState(false) (aucun état persistant). const goTo = (i) => setIndex(((i % n) + n) % n) (boucle : précédent depuis la première citation mène à la dernière, suivant depuis la dernière mène à la première ; les boutons ne sont jamais désactivés).

Empilement : toutes les citations sont dans le DOM (SSR, indexables). Les N panneaux occupent la même cellule d'une grille CSS (grid-area: 1 / 1), tous montés en permanence ; le panneau actif est animé (opacité + translation Y 12 px, voir §6.8.9) ; les panneaux inactifs ont visibility: hidden (appliqué en fin de transition), pointer-events: none, aria-hidden="true" et l'attribut inert. Ces trois attributs ne sont posés qu'après le montage (mounted), de sorte que le HTML rendu côté serveur et la lecture sans JavaScript exposent toutes les citations. Le viewport prend la hauteur du panneau le plus haut et la mise en page ne bouge pas. Le composant StableSwap n'est pas utilisé ici (réservé au résultat de la boussole, §6.6).

État Rendu
SSR / avant hydratation Première citation visible, les autres empilées derrière ; contrôles non rendus tant que mounted === false (aucun contrôle inopérant)
Normal Une citation visible, contrôles actifs, région d'annonces aria-live="off"
Après la première interaction Région d'annonces aria-live="polite" ; elle reçoit liveRegion.slideChanged à chaque changement
Transition La citation sortante passe à l'opacité 0 en fast puis visibility: hidden + inert ; l'entrante apparaît en fondu (voir §6.8.9)
Focus clavier Anneau turquoise-600 sur boutons et points
Sans JavaScript (@media (scripting: none)) Toutes les citations sont affichées l'une sous l'autre (grille repliée en liste verticale), les contrôles sont masqués
Une seule citation publiable Pas de carrousel, pas de contrôles, pas de rôles carrousel
Mouvement réduit Fondu instantané (≤ 0,15 s) sans translation

Pas de lecture automatique. Décision : aucun défilement automatique, ni au chargement, ni au survol, ni au focus. Raisons : critère WCAG 2.2.2 (Pause, Stop, Hide), lecture de citations longues, simplicité. Par conséquent aucun bouton Pause n'est nécessaire.

6.8.6 Interactions #

Boutons précédent / suivant : <button type="button"> 48 × 48 px, icônes Lucide ChevronLeft / ChevronRight (aria-hidden), aria-label = controls.previousLabel / controls.nextLabel.

Points : un <button type="button"> par citation, aria-label = controls.paginationAriaLabel ({n}, {total}), aria-current="true" sur le point actif. Le point visuel mesure 10 px (14 px et anneau pour le point actif) ; la zone cliquable fait 44 × 44 px (utilitaire hit-area), les zones adjacentes ne se chevauchent pas ; la rangée passe à la ligne si elle ne tient pas dans la largeur.

Clavier :

  • Ordre de tabulation : précédent → points → suivant ; chaque contrôle est un bouton natif activé par Entrée ou Espace.
  • Les touches fléchées ne sont interceptées nulle part (pas d'écouteur global, aucun comportement de flèche sur les boutons).
  • Après activation d'un bouton, le focus reste sur ce bouton ; la région d'annonces énonce la nouvelle citation.

Souris : clic sur un bouton ou un point. Aucun survol particulier (pas de pause, pas d'autoplay).

Tactile :

  • Balayage horizontal sur le viewport : touch-action: pan-y sur le viewport (le défilement vertical reste natif). Détection par pointerdown / pointerup (types touch et pen uniquement) : déclenchement si |dx| ≥ 48 px et |dx| ≥ 1,5 × |dy| ; dx < 0 → suivant, dx > 0 → précédent ; pointercancel annule. Aucune capture de pointeur, aucun suivi du doigt.
  • Alternative sans geste (WCAG 2.5.7) : les boutons précédent/suivant (48 px) et les points (zone 44 px) offrent le même résultat par simple appui ; aucune fonction n'est exclusive au balayage.
  • Pas de drag Motion (n'exige pas la fonctionnalité domMax, voir §8).

6.8.7 Citation unique (N = 1) #

Disposition identique à la citation du carrousel (mêmes tailles, même bloc) sans conteneur carrousel, sans boutons, sans points, sans compteur ; la figure est un simple <figure> dans la section. Aucun espace vide laissé par les contrôles absents.

6.8.8 Typographie de la citation #

Élément Règle
Citation Fraunces (variante italique chargée, §5.5.1), graisse 300 ; font-size: clamp(1.375rem, 1rem + 1.6vw, 2.25rem) ; interligne 1,35 ; couleur ink-900 ; mesure max 38ch × 2 sur desktop, 100 % sur mobile
Citation longue (> 360 caractères) Classe testimonial-long : taille réduite d'un cran (clamp(1.125rem, 0.95rem + 0.9vw, 1.5rem)) pour éviter une hauteur excessive
Guillemets « » Ajoutés par TestimonialCard autour du texte, même police et même couleur que la citation, espaces insécables à l'intérieur ; le texte de Malka n'est jamais modifié
Pictogramme décoratif Icône Lucide Quote (§5.8), champagne-600, 32 px desktop / 24 px mobile, aria-hidden, placée au-dessus à gauche, sans chevaucher le texte (marge basse 16 px)
Nom (et contexte) Manrope 600, 1 rem, ink-900 ; contexte Manrope 400, 0,9375 rem, ink-600
Étiquette d'offre Pastille 9999 px, bordure sage-400 1 px, fond sage-200, texte forest-800 0,8125 rem Manrope 600 ; non interactive (<span>)
Texte de citation Rendu tel quel, jamais tronqué

6.8.9 Accessibilité, motion, analytics #

Accessibilité :

  • Le conteneur du carrousel est une <section aria-roledescription={roleDescriptions.carousel} aria-label={regionAriaLabel}> ; chaque citation est un <div role="group" aria-roledescription={roleDescriptions.slide} aria-label={slideLabel}> (valeurs de aria-roledescription issues des clés roleDescriptions.* du schéma §9.3.3, en français : « carrousel » et « témoignage »).
  • Région d'annonces dédiée (sr-only, aria-atomic="true") : aria-live="off" jusqu'à la première interaction de l'utilisateur, puis aria-live="polite" ; elle contient liveRegion.slideChanged. Les panneaux eux-mêmes ne sont pas des régions live.
  • Point actif indiqué par aria-current et par un état visuel non chromatique (point plein agrandi à 14 px + anneau).
  • Contraste du texte ≥ 7:1 (ink-900 / ivory) ; étiquette d'offre ≥ 4,5:1.
  • Boutons précédent/suivant 48 px ; points : zone cliquable 44 × 44 px.
  • La zone de citation n'est pas focalisable (aucun tabindex sur le texte).

Motion (voir §8, qui possède les valeurs) :

  • Entrée de section : révélation de l'en-tête puis de la citation, base 0,5 s.
  • Changement : tous les panneaux restent montés ; le panneau entrant passe de opacité 0 / translation Y 12 px à opacité 1 / Y 0 sur base 0,5 s (easeSoft) ; le sortant passe à l'opacité 0 en fast 0,25 s, puis reçoit visibility: hidden et inert (une seule citation visible à la fin de chaque transition).
  • Mouvement réduit : opacité seulement, instantanée (≤ 0,15 s) ; aucune translation. Mobile : mêmes règles que desktop (aucun effet coûteux).
  • Aucun autoplay, aucune boucle ambiante.

Analytics (voir §9.8) : aucun événement dédié aux témoignages ; track() n'est pas appelé dans cette section.

6.8.10 Cas limites #

  1. Zéro témoignage publiable : section, lien de navigation, lien de pied de page absents ; l'URL /#temoignages n'a aucun effet (le navigateur reste en haut de page, aucune erreur).
  2. published:true avec consent.status:'pending' ou citation vide : refusé au build avec un message précis par entrée.
  3. Deux témoignages de longueurs très différentes : le viewport garde la hauteur du plus long ; sur mobile, l'espace sous la citation courte est assumé (pas de saut).
  4. Quote contenant des sauts de ligne : conservés (via white-space: pre-line), pas de HTML interprété (échappement React).
  5. Redimensionnement ou rotation de l'écran : la hauteur du viewport suit la grille CSS, l'index courant est conservé.
  6. Changement d'index pendant que le lecteur d'écran lit : la région polie attend la fin de la lecture en cours.
  7. Utilisateur de clavier activant « suivant » : le focus reste sur le bouton ; la région d'annonces énonce la citation ; le point actif se met à jour.
  8. offerId référence une offre retirée : le build échoue (validation croisée) ; défensivement, l'étiquette est omise.
  9. Impression : toutes les citations publiables s'impriment l'une sous l'autre (@media print : panneaux visibles, contrôles masqués).
  10. Aucune donnée de consentement ni de date n'est affichée au public.
  11. Citation d'un seul mot ou très courte : acceptée (aucun minimum) ; la mise en page reste valide.

6.8.11 Critères d'acceptation #

  • AC-6.8-01 — Avec les données livrées (published:false), le HTML de / ne contient ni id="temoignages", ni lien vers #temoignages (navigation, menu mobile, pied de page).
  • AC-6.8-02 — Avec une entrée published:true, consent.status:'granted', grantedOn et citation non vide (y compris courte), la section apparaît, le lien de navigation apparaît, et aucune commande de carrousel n'est rendue.
  • AC-6.8-03 — Avec deux entrées publiables, après le montage, un seul panneau est visible et accessible ; l'autre est aria-hidden="true" et inert, mais présent dans le DOM.
  • AC-6.8-04 — « Suivant » depuis la dernière citation affiche la première ; « Précédent » depuis la première affiche la dernière.
  • AC-6.8-05 — Les points sont des boutons ; un seul porte aria-current="true" ; chacun a une zone cliquable ≥ 44 × 44 px et un aria-label issu de controls.paginationAriaLabel ; les flèches du clavier ne changent pas de citation.
  • AC-6.8-06 — Le conteneur est une <section> portant aria-roledescription valant roleDescriptions.carousel (« carrousel ») ; chaque panneau porte role="group" et aria-roledescription valant roleDescriptions.slide (« témoignage »).
  • AC-6.8-07 — Aucune citation ne change sans action de l'utilisateur, après 60 s sans interaction (absence d'autoplay).
  • AC-6.8-08 — Un balayage horizontal tactile de 80 px change de citation ; les boutons précédent/suivant produisent le même résultat par simple appui (WCAG 2.5.7).
  • AC-6.8-09 — Un balayage vertical n'est jamais détecté comme un changement de citation et le défilement de la page reste fluide.
  • AC-6.8-10 — La hauteur du viewport est identique avant et après un changement de citation (aucun CLS).
  • AC-6.8-11 — Boutons précédent/suivant ≥ 48 × 48 px ; points : zone cliquable ≥ 44 × 44 px.
  • AC-6.8-12 — prefers-reduced-motion: reduce : aucune translation, fondu ≤ 0,15 s.
  • AC-6.8-13 — Un published:true sans consentement granted fait échouer pnpm build avec un message désignant l'entrée.
  • AC-6.8-14 — Aucun texte de citation, nom ou chiffre n'apparaît dans le code source en dehors de src/content/fr/testimonials.ts.
  • AC-6.8-15 — Axe ne rapporte aucune violation pour les états N = 1 et N ≥ 2.
  • AC-6.8-16 — La région d'annonces est aria-live="off" avant toute interaction et aria-live="polite" après ; avec JavaScript désactivé (@media (scripting: none)), toutes les citations sont visibles et lisibles et aucun contrôle n'est affiché.
  • AC-6.8-17 — Le texte rendu de chaque citation est encadré de guillemets « » ajoutés par le composant ; la citation du fichier de données n'en contient pas et reste inchangée.

6.9 Contact et prise de rendez-vous (I) #

6.9.1 Objectif #

Section #contact : transformer la visite en prise de contact réelle par deux voies équivalentes, WhatsApp (voie principale, la plus directe) ou formulaire (voie écrite). Aucun calendrier en ligne au lancement ; l'architecture reste prête pour Cal.com (voir §6.9.5). Aucune réponse n'est promise dans un délai donné : aucun délai de réponse n'est affiché nulle part dans la section (ni « sous 24 h », ni « rapidement »).

6.9.2 Mise en page par breakpoint #

Desktop (≥ 1024 px) — deux colonnes : carte WhatsApp (colonnes 1–5), formulaire (colonnes 6–12) ; en-tête de section au-dessus sur toute la largeur.

┌──────────────────────────────────────────────────────────────────────────┐
│ CONTACT (eyebrow)                                                        │
│ Titre H2                                          Intro (cols 8–12)      │
├───────────────────────────┬──────────────────────────────────────────────┤
│ ┌───────────────────────┐ │  Formulaire — titre H3 + phrase d'introduction│
│ │  CARTE WHATSAPP       │ │  Nom et prénom                               │
│ │  (forest-900)         │ │  E-mail              Téléphone (facultatif)  │
│ │  Texte court          │ │  Accompagnement      Votre situation         │
│ │  [ Écrire sur         │ │    (facultatif)        (facultatif)          │
│ │    WhatsApp ]         │ │  Message                                     │
│ │  06 67 29 51 79       │ │  ☐ consentement + lien confidentialité       │
│ │  astheoris.pro@…      │ │  [ Envoyer ma demande ]                      │
│ │  France — à distance  │ │                                              │
│ └───────────────────────┘ │                                              │
└───────────────────────────┴──────────────────────────────────────────────┘
  • Téléphone / e-mail sur une même ligne (2 colonnes de la grille interne du formulaire) à partir de 1024 px ; offre / situation de même.
  • Pas de colonne collante (sticky) : aucun risque de chevauchement avec le pied de page flottant (voir §6.10.4).
  • Padding vertical de la section : token --spacing-section (py-section, §5.6.1) en haut ; en bas, padding-bottom: calc(var(--spacing-section) + var(--footer-overlap)) pour réserver la zone recouverte par la carte du pied de page et garantir au moins 64 px d'espace libre (voir §6.10.4). scroll-margin-top = hauteur d'en-tête + 16 px.

Tablette (768–1023 px) : une colonne (carte WhatsApp d'abord, puis formulaire), largeur max 720 px centrée ; champs sur deux colonnes pour téléphone/e-mail et offre/situation.

Mobile (< 768 px) : une colonne ; carte WhatsApp d'abord (voie principale), puis formulaire ; tous les champs pleine largeur ; bouton d'envoi pleine largeur (56 px).

6.9.3 Source de contenu #

  • Coordonnées : site.ts → contact (phoneDisplay, phoneE164, email, location) ; le lien WhatsApp vient de getBookingAction({}), appelé côté serveur (voir §9).
  • Textes : src/content/fr/contact.ts (schéma contactSchema, §9.3.3), valeurs en §7.11. §7.11 est la seule source des libellés, aides, messages d'erreur et textes de succès : ce document ne les reproduit pas et ne cite que les clés ci-dessous. Aucune chaîne française destinée au visiteur n'est écrite dans les composants (hors libellé technique du champ piège, §6.9.9).
  • Libellés fixés par décision : bouton WhatsApp « Écrire sur WhatsApp », lien vers le formulaire « Écrire via le formulaire », bouton d'envoi « Envoyer ma demande ». Aucune réponse n'est promise dans un délai : aucun texte de la carte, du formulaire, du succès ni de l'accusé de réception n'annonce un délai. Le site parle de « Malka » à la troisième personne, jamais de « nous ».
  • Clés de texte consommées (toutes dans contact.*) :
Zone Clés
En-tête de section eyebrow, title, intro
Carte WhatsApp whatsappCard.title, whatsappCard.text, whatsappCard.ctaLabel, whatsappCard.ctaNewTabSuffix, whatsappCard.defaultMessage
Formulaire form.title (H3), form.intro (précise que les champs non marqués « facultatif » sont obligatoires), form.dataNote (légende sous le bouton d'envoi), fields.optionalSuffix, fields.characterCount ({n}, {max})
Champs fields.name|email|phone|message.label et .hint ; fields.offerId.label|hint|noneOptionLabel ; fields.situationId.label|hint|noneOptionLabel ; fields.consent.label (un seul {privacyLink}), fields.consent.privacyLinkLabel, fields.consent.privacyLinkNewTabSuffix ; fields.website.label
Validation validation.<champ>.<règle> (tableau 6.9.8) et validation.generic
Envoi submit.idleLabel (« Envoyer ma demande »), submit.loadingLabel, submit.loadingAriaLive
Erreurs apiErrors.<CODE> (§9.3.3) ; errorSummary.titleOne|titleMany ({n}) ; failureBanner.title|fallbackIntro|retryLabel|emailLinkLabel|emailSubject ; retryAfter.underMinute|minuteOne|minuteMany|hourOne|hourMany|unknown ({n})
Succès success.title, success.body, success.resetLabel (le bouton WhatsApp du succès réutilise whatsappCard.ctaLabel et le lien produit par getBookingAction({}))
Préremplissage (annonce vocale) prefillAnnouncement.byOffer ({offerTitle}), prefillAnnouncement.bySituation ({situationLabel})
Sans script noscript.text

Toutes ces clés font partie du schéma contactSchema (§9.3.3), qui ne comporte ni gabarit de message prérempli ni clé prefill : le champ message n'est jamais prérempli.

  • Offres de la liste déroulante : offers.ts (titre et id) ; situations : orientation.ts (label et id).
  • Schéma de validation partagé : contactRequestSchema (src/lib/contact/schema.ts, §9.4). Aucune règle de validation n'est redéfinie dans le composant.

6.9.4 Arbre de composants et props #

<ContactSection>                          src/components/sections/contact/contact-section.tsx (server)
│                                         <section id="contact" aria-labelledby="contact-titre" tabindex="-1">
├─ <SectionHeader eyebrow title intro />
├─ <WhatsappCard action contact />        src/components/sections/contact/whatsapp-card.tsx (server)
├─ <ContactFallback text hrefs />         (server, défini dans contact-section.tsx ; visible seulement sous @media (scripting: none))
└─ <ContactForm copy offers situations fallback />   src/components/forms/contact-form.tsx (client)
   ├─ <FormErrorSummary errors onFocusField />        (form-feedback.tsx)
   ├─ <SubmitFailureBanner failure onRetry />         (form-feedback.tsx)
   ├─ <FormField name="name" />                       (form-field.tsx)
   ├─ <FormField name="email" />
   ├─ <FormField name="phone" />
   ├─ <FormField name="offerId" as="select" />
   ├─ <FormField name="situationId" as="select" />    (visible, facultatif)
   ├─ <FormField name="message" as="textarea" />
   ├─ <ConsentCheckbox />                             (consent-checkbox.tsx)
   ├─ <HoneypotField />                               (honeypot-field.tsx)
   ├─ <button type="submit" aria-disabled={submitting}>
   ├─ <div role="status" aria-live="polite" class="sr-only" />
   └─ <ContactSuccess onReset />                      (form-feedback.tsx ; remplace le formulaire)

Aucun <fieldset disabled> et aucun composant de préremplissage par useSearchParams : l'état d'envoi passe par aria-busy, readOnly et aria-disabled (6.9.10) ; le préremplissage est décrit en 6.9.7. La machine d'états vit dans src/components/forms/use-contact-form.ts.

interface ContactFormProps {
  copy: Content['contact'];        // sérialisé par le serveur (type de §9.3.3) ; l'îlot n'importe aucun contenu
  offers: Pick<Offer, 'id' | 'title'>[];
  situations: Pick<OrientationSituation, 'id' | 'label'>[];
  fallback: { whatsappHref: string; mailtoHref: string; telHref: string };   // calculés côté serveur
}

interface FieldProps {
  id: string;                      // 'contact-name', 'contact-email', …
  name: keyof ContactFormValues;
  label: string;
  hint?: string;
  optional?: boolean;              // ajoute fields.optionalSuffix au libellé
  error?: string;
  autoComplete?: string;
  inputMode?: 'text' | 'email' | 'tel';
  maxLength?: number;
}

interface ContactFormValues {
  name: string; email: string; phone: string;
  offerId: string;                 // 'non-defini' par défaut
  situationId: string;             // '' par défaut
  message: string; consent: boolean; website: string;
}

// Dérivé de SubmitOutcome (§9.4.8, champs `status` et `code`) par le tableau 6.9.11 : une seule correspondance.
type SubmitFailureKind =
  | 'validation' | 'rate_limited' | 'delivery' | 'internal' | 'technical'
  | 'forbidden_origin' | 'too_large' | 'offline' | 'network' | 'timeout';

interface SubmitFailure {
  kind: SubmitFailureKind;
  message: string;                 // `outcome.message` (texte français issu de contact.apiErrors)
  retryAfterSeconds?: number;      // pour 'rate_limited'
  showFallback: boolean;           // liens WhatsApp / e-mail
}

type FormStatus = 'idle' | 'submitting' | 'success' | 'error';

6.9.5 Carte WhatsApp et coordonnées (colonne gauche) #

Carte forest-900, texte ivory-50, rayon 20 px, padding 32 px (24 px mobile). Contenu, dans l'ordre :

  1. Pictogramme MessageCircle (aria-hidden) et titre h3 (whatsappCard.title).
  2. Texte court (whatsappCard.text) : présente WhatsApp comme la voie la plus directe. Aucun délai de réponse, aucune promesse de disponibilité.
  3. Bouton principal « Écrire sur WhatsApp » (whatsappCard.ctaLabel) : <a> pilule champagne-400 (texte forest-950, contraste ≥ 8:1), hauteur 56 px, target="_blank" rel="noopener noreferrer", texte masqué whatsappCard.ctaNewTabSuffix. Son href vient de getBookingAction({}) (message générique whatsappCard.defaultMessage).
  4. Liste de coordonnées (<ul>, icônes Lucide aria-hidden) :
Ligne Icône Rendu Lien
Téléphone Phone 06 67 29 51 79 (phoneDisplay, white-space: nowrap) tel:+33667295179 (phoneE164)
E-mail Mail astheoris.pro@gmail.com mailto:astheoris.pro@gmail.com
Localisation MapPin site.contact.location aucun (texte)

Les liens tel: et mailto: ont une zone cliquable de 44 px de haut ; texte de l'e-mail autorisé à passer à la ligne (overflow-wrap: anywhere).

Extension Cal.com : la carte lit const action = getBookingAction({}) et rend un switch (action.kind) avec l'unique cas 'whatsapp'. L'ajout futur d'un fournisseur 'calcom' consiste à ajouter un cas (composant d'intégration, variable d'environnement prévue en §13) sans modifier la mise en page ni le formulaire. Aucun code Cal.com n'est écrit dans le MVP.

6.9.6 Champs du formulaire (ordre, libellés, aides, attributs) #

Le formulaire : <form id="contact-form" noValidate aria-labelledby="contact-form-title" aria-describedby="contact-form-intro">. Convention retenue : pas d'astérisque ; les champs facultatifs portent le suffixe fields.optionalSuffix dans leur libellé, tous les autres sont obligatoires, ce que précise form.intro. Aucun placeholder ne porte d'information : l'aide est un texte visible relié par aria-describedby.

# Champ (name) id Textes (clés de 6.9.3) Contrôle autocomplete Autres attributs Valeur initiale
1 name contact-name fields.name.label / .hint input type="text" name maxLength=80, spellcheck=false, autocapitalize="words" ''
2 email contact-email fields.email.label / .hint input type="email" email inputMode="email", maxLength=254, autocapitalize="none", spellcheck=false ''
3 phone contact-phone fields.phone.label / .hint + suffixe facultatif input type="tel" tel inputMode="tel", maxLength=20 ''
4 offerId contact-offer fields.offerId.label / .hint + suffixe facultatif select off — non-defini
5 situationId contact-situation fields.situationId.label / .hint + suffixe facultatif select off visible, jamais masqué ''
6 message contact-message fields.message.label / .hint textarea rows=6 off maxLength=2000, spellcheck=true, lang="fr", min-height 160 px, redimensionnement vertical ; jamais prérempli ''
7 consent contact-consent fields.consent.label (avec lien) input type="checkbox" — zone cliquable 44 px décochée
8 website contact-website voir §6.9.9 input type="text" off hors écran ''

Options du select « Accompagnement concerné » : première option de valeur non-defini, libellé fields.offerId.noneOptionLabel ; puis les offres dans l'ordre order de offers.ts (titre complet). Le prix n'est pas affiché dans les options.

Options du select « Votre situation » : première option de valeur '', libellé fields.situationId.noneOptionLabel ; puis les quatre situations (label). Ce champ est un contrôle visible et facultatif : le visiteur voit et peut modifier ou vider ce que la boussole y a déposé.

Compteur de caractères : sous le message, texte visible « {n} / 2000 » (non aria-live), couleur error à partir de 1 950 caractères ; référencé par aria-describedby du textarea avec l'aide.

Case de consentement : libellé fields.consent.label dans lequel {privacyLink} est remplacé par <a href="/politique-de-confidentialite" target="_blank" rel="noopener noreferrer"> portant fields.consent.privacyLinkLabel et un texte masqué fields.consent.privacyLinkNewTabSuffix. Le lien s'ouvre dans un nouvel onglet : le formulaire conserve son état. La case est une reconnaissance de l'information fournie (transparence), pas la base légale du traitement, laquelle est précisée dans §6.11.4.

Bouton d'envoi : submit.idleLabel (« Envoyer ma demande »), pilule forest-800/texte ivory-50 sur fond clair, hauteur 56 px (mobile : pleine largeur). Légende sous le bouton (taille 14 px) : form.dataNote.

6.9.7 Préremplissage : cartes d'offre, boussole, adresse ?offre= #

Mécanisme unique, dont le propriétaire est §6.5.4 (module src/lib/contact/prefill.ts) : requestContactPrefill({ offerId?, situationId? }) dépose les valeurs dans un magasin en mémoire (variable de module), émet CustomEvent('contact:prefill') sur window, fait défiler #contact en vue et place le focus sur la racine #contact. Le formulaire :

  1. au montage, lit d'abord le magasin en mémoire (un appel survenu avant l'hydratation n'est pas perdu), puis lit une seule fois le paramètre offre de window.location.search par parsePrefill (chargement direct de /?offre=<id>#contact, cas du lien « Écrire via le formulaire » d'une offre ouvert sans JavaScript ou dans un nouvel onglet) ; après lecture, il retire le paramètre de la barre d'adresse par history.replaceState(null, '', location.pathname + location.hash) ;
  2. s'abonne à contact:prefill et applique chaque événement reçu ;
  3. n'utilise ni useSearchParams ni <Suspense> (la page / reste statique).
Source Valeur Effet
offerId : carte d'offre (§6.5), boussole (§6.6), paramètre offre au chargement l'un des quatre ids d'offre Sélectionne l'option correspondante dans contact-offer
situationId : boussole (§6.6) uniquement l'un des quatre ids de situation Sélectionne l'option correspondante dans contact-situation ; jamais lue depuis une URL

Règles :

  1. Une valeur inconnue, vide ou dupliquée est ignorée silencieusement (aucune erreur, champ inchangé).
  2. Le préremplissage s'applique à chaque événement, y compris lorsqu'un visiteur clique sur une autre carte d'offre après avoir modifié le champ à la main : le clic explicite prévaut. Au montage, seul un champ intact est prérempli. Une valeur absente ne modifie pas le champ.
  3. Le préremplissage ne déplace pas le focus du formulaire (le focus va à la racine #contact, §6.5.4) et ne soumet rien.
  4. Le champ « message » n'est jamais prérempli ni modifié : aucun texte généré n'est inséré.
  5. Après application, la région live du formulaire annonce prefillAnnouncement.byOffer ({offerTitle}) et, le cas échéant, prefillAnnouncement.bySituation ({situationLabel}).
  6. La situation ne figure dans aucune URL, aucun cookie, aucun stockage et aucun événement de mesure. Le script Umami est chargé avec data-exclude-search="true" et data-exclude-hash="true" (§9.8.2) : même le paramètre offre n'est pas transmis à l'outil de mesure.
  7. Les valeurs sélectionnées sont visibles et modifiables ; elles ne sont envoyées au serveur que si le visiteur soumet le formulaire.

Le formulaire n'est pas rendu avant hydratation (voir §6.9.13) ; le magasin en mémoire et le paramètre lus au montage sont donc disponibles dès le premier rendu visible.

6.9.8 Validation côté client #

Bibliothèque : le schéma Zod partagé contactRequestSchema (§9.4). Le composant n'ajoute que deux conversions avant d'appeler safeParse : (1) chaînes vides de phone et situationId → undefined ; (2) les valeurs sont passées après trim().

Règles rappelées (référence : §9.4) et clés du message affiché (texte en §7.11.3) :

Champ Règle Clé du message
name 2 à 80 caractères après trim, sans caractère de contrôle validation.name.required / tooShort / tooLong / invalid
email e-mail valide, ≤ 254 validation.email.required / invalid / tooLong
phone facultatif ; ^[0-9 +().-]{6,20}$ validation.phone.invalid / tooLong
offerId facultatif ; id valide ou non-defini validation.offerId.invalid
situationId facultatif ; id valide validation.situationId.invalid
message 20 à 2000 caractères après trim, 2 liens au plus validation.message.required / tooShort / tooLong / tooManyLinks
consent doit être coché validation.consent.required

Les messages viennent des issues Zod du schéma partagé, qui portent les clés ci-dessus. Si une issue n'a pas de clé reconnue, l'interface affiche validation.generic ; elle n'affiche jamais un message par défaut anglais de Zod.

Moments de validation :

  • Au blur d'un champ, seulement s'il a été modifié : un champ obligatoire simplement traversé au clavier sans saisie n'affiche pas d'erreur. Le champ modifié est marqué « touché » puis validé seul.
  • À la saisie : si le champ affiche une erreur, il est revalidé à chaque frappe pour effacer l'erreur dès qu'elle est corrigée ; un champ valide n'affiche jamais d'erreur « prématurée » pendant qu'on tape.
  • À la soumission : tous les champs sont validés ; tous deviennent « touchés ».
  • Aucun message de succès par champ (pas de coche verte).

Affichage d'une erreur de champ : texte sous le champ avec icône CircleAlert (aria-hidden), couleur du token error, id="{id}-error" ; le champ reçoit aria-invalid="true", une bordure error de 2 px, et aria-describedby="{id}-hint {id}-error". L'erreur n'est pas un role="alert" individuel (annonce portée par le récapitulatif).

Récapitulatif d'erreurs (à la soumission invalide) : bloc au-dessus du formulaire, <div id="contact-error-summary" tabindex="-1" aria-labelledby="contact-error-summary-title"> ; titre h3 errorSummary.titleOne / errorSummary.titleMany ({n} = nombre d'erreurs) ; liste de liens <a href="#contact-name">{message}</a> dans l'ordre visuel des champs. Au clic, preventDefault, focus du champ (focus() suivi de scrollIntoView({ block:'center' }) sans animation) ; le récapitulatif se met à jour dynamiquement (les entrées corrigées disparaissent ; vide → le bloc disparaît). Au moment d'une soumission invalide, le focus est placé sur le titre du récapitulatif (tabindex="-1"). Aucun role="alert" n'est ajouté pour éviter la double annonce.

6.9.9 Anti-spam côté client : honeypot et startedAt #

Honeypot (website) : champ <input type="text" name="website" id="contact-website" tabIndex={-1} autoComplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"> à l'intérieur d'un conteneur aria-hidden="true", positionné hors écran par CSS (position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden). Interdit : display:none, visibility:hidden, hidden, type="hidden", opacity:0 seul (les robots les détectent ; l'objectif est qu'un lecteur d'écran ne l'expose pas, ce que garantissent aria-hidden et tabindex=-1). Le libellé associé est un libellé technique interne au conteneur masqué (non éditorial, jamais exposé). La valeur est toujours incluse dans la charge utile ; le serveur abandonne silencieusement toute soumission où elle est non vide (§9.4), l'interface affiche alors le succès normal.

startedAt : le composant capture l'instant de la première interaction (focusin ou input sur le formulaire, startedAt.current ??= Date.now()) et le remet à null à « Envoyer un autre message ». Toutes les autres règles — recalage d'horloge par GET /api/health, attente minimale avant l'envoi, valeur envoyée dans la charge utile — sont définies une seule fois en §9.4.8 et appliquées par submitContact ; le composant n'en redéfinit aucune.

6.9.10 Soumission et machine d'états #

idle ──submit(valid)──▶ submitting ──ok──▶ success ──« Envoyer un autre message »──▶ idle
  ▲                         │
  │                         └──failure──▶ error ──edit/retry──▶ idle | submitting
  └──submit(invalid)──▶ idle + récapitulatif d'erreurs

Séquence de onSubmit :

  1. event.preventDefault(). Si status === 'submitting' ou submittingRef.current === true : retour immédiat (prévention de double envoi). Si un délai Retry-After est actif : retour + rappel du message.
  2. Valider ; si invalide : marquer les champs, afficher le récapitulatif, focus sur son titre, aucun événement analytics.
  3. submittingRef.current = true ; status = 'submitting' ; vider failure.
  4. Appeler submitContact(payload, { signal, timeoutMs: 30_000, apiErrors }) (§9.4.8), qui applique l'attente minimale liée à startedAt, le délai maximal côté client de 30 s (aucune relance automatique) et la normalisation de la réponse en SubmitOutcome.
  5. Dériver le SubmitFailure du SubmitOutcome par le tableau 6.9.11 ; mettre à jour l'état ; submittingRef.current = false.

Charge utile (payload), conforme à §9.4 : name, email, phone (omis si vide), offerId (non-defini ou id), situationId (omis si vide), message, consent: true, website, startedAt. Les chaînes sont passées par trim().

État « envoi en cours » (le focus n'est jamais perdu) :

  • Le <form> porte aria-busy="true". Les champs texte et le textarea passent en readOnly ; les selects et la case ignorent tout changement et portent aria-disabled="true" ; il n'y a pas de <fieldset disabled>.
  • Bouton : aria-disabled="true" (il reste focalisable), libellé submit.loadingLabel, icône LoaderCircle en rotation (CSS, 1 s, linéaire) ; sous prefers-reduced-motion, l'icône est immobile (le texte porte l'information).
  • La région live annonce submit.loadingAriaLive.
  • Une deuxième soumission (Entrée, clic, double-tap) est ignorée.
  • AbortController.abort() à la désinstallation du composant (résultat aborted : l'interface ne réagit pas).

Focus après résultat : succès → titre de la confirmation ; échec → titre de la bannière d'erreur (tabindex="-1") ; les valeurs saisies sont conservées intactes.

6.9.11 Table complète des erreurs #

Le composant ne lit jamais la réponse HTTP : il reçoit un SubmitOutcome (§9.4.8, champ status ; code pour server_error). Le message affiché est outcome.message, c'est-à-dire le texte français issu de contact.apiErrors.<code> (§7.11.5) ; pour network_error, c'est contact.apiErrors.NETWORK_ERROR (ou contact.apiErrors.OFFLINE si navigator.onLine === false) et pour timeout, contact.apiErrors.TIMEOUT. Aucune information technique n'est affichée.

SubmitOutcome (status / code) HTTP kind Message Action de récupération
validation 422 validation Récapitulatif d'erreurs + message de chaque champ de outcome.fields ; sans fields : validation.generic Corriger les champs signalés ; focus sur le récapitulatif ; données conservées
server_error / INVALID_JSON 400 technical apiErrors.INVALID_JSON failureBanner.retryLabel + liens de secours
server_error / UNSUPPORTED_MEDIA_TYPE 415 technical apiErrors.UNSUPPORTED_MEDIA_TYPE idem
server_error / METHOD_NOT_ALLOWED 405 technical apiErrors.METHOD_NOT_ALLOWED idem
server_error / PAYLOAD_TOO_LARGE 413 too_large apiErrors.PAYLOAD_TOO_LARGE Modifier le message ; focus sur le textarea après fermeture de la bannière ; pas de liens de secours
server_error / FORBIDDEN_ORIGIN 403 forbidden_origin apiErrors.FORBIDDEN_ORIGIN Liens de secours ; pas de bouton de nouvel essai
rate_limited 429 rate_limited apiErrors.RATE_LIMITED suivi de retryAfter.* Bouton d'envoi inactif jusqu'à l'échéance (aria-disabled, minuterie unique, pas de compte à rebours animé) ; liens de secours immédiatement disponibles
delivery_failed 502 delivery apiErrors.EMAIL_DELIVERY_FAILED failureBanner.retryLabel + liens de secours WhatsApp et e-mail
server_error / INTERNAL_ERROR, code inconnu, réponse non JSON, 5xx sans enveloppe ≥ 500 ou autre internal apiErrors.INTERNAL_ERROR failureBanner.retryLabel + liens de secours
network_error avec navigator.onLine === false — offline apiErrors.OFFLINE failureBanner.retryLabel ; pas de liens de secours (inopérants hors ligne) ; la bannière disparaît à l'événement online
network_error en ligne — network apiErrors.NETWORK_ERROR failureBanner.retryLabel + liens de secours
timeout (30 s) — timeout apiErrors.TIMEOUT failureBanner.retryLabel + liens de secours ; la déduplication serveur (§9.4.6) évite le doublon si le message était déjà parti
aborted — aucun aucun aucune

Durée du 429 : outcome.retryAfterSeconds (déjà borné à [1, 86 400] par §9.4.8, 60 par défaut) ; n = ⌈s/60⌉ ; le gabarit est choisi dans retryAfter.* : moins d'une minute → underMinute ; 1 minute → minuteOne ; n minutes → minuteMany ({n}) ; 1 heure → hourOne ; n heures → hourMany ({n}) ; durée inconnue → unknown. Le bouton se réactive à l'échéance par un unique setTimeout, nettoyé au démontage.

Bannière d'erreur (SubmitFailureBanner) : bloc tabindex="-1" sous le récapitulatif, aria-labelledby du titre h3 failureBanner.title, icône CircleAlert, texte du message, puis les actions. Aucune information technique (code, requestId, statut HTTP) n'est affichée au visiteur.

Liens de secours (affichés quand showFallback) :

  • Bouton « Écrire sur WhatsApp » (fallback.whatsappHref, nouvel onglet) — émet cta_click contact_whatsapp.
  • Lien failureBanner.emailLinkLabel : mailto: + site.contact.email avec subject = failureBanner.emailSubject encodé ; le corps reprend le message du visiteur seulement si l'URL encodée complète ≤ 1 800 caractères, sinon le corps est omis (le visiteur voit toujours son message dans le champ pour le copier).

Texte de la bannière : jamais de reproche (« vous avez fait une erreur ») ; ton sobre.

6.9.12 Succès #

Sur SubmitOutcome.status === 'success', l'état du formulaire passe à 'success' et ses valeurs sont effacées de l'état immédiatement (aucun maintien de données personnelles en mémoire). Le <form> est remplacé (même emplacement, min-height conservée) par ContactSuccess :

┌──────────────────────────────────────────────┐
│ ✓  (icône CircleCheck, couleur success)      │
│ Titre H3 (success.title)                     │ ← focus (tabindex -1)
│ Texte (success.body) : demande transmise.    │
│ Aucun délai de réponse annoncé.              │
│ [ Écrire sur WhatsApp ] (whatsappCard.ctaLabel) │
│ [ success.resetLabel ]            (secondaire) │
└──────────────────────────────────────────────┘
  • Focus déplacé sur le titre (ref.focus() avec scroll-margin-top ≥ hauteur d'en-tête + 16 px pour que le titre reste visible).
  • Le texte ne promet ni délai ni réponse à une échéance, et ne répète aucune donnée saisie (voir §9.5 : l'accusé de réception est envoyé selon la configuration serveur, l'interface ne peut pas le garantir).
  • Soumission abandonnée silencieusement par le serveur (anti-spam, §9.4) : même écran de succès.
  • success.resetLabel : remet status = 'idle', valeurs initiales (offre non-defini, situation vide, consentement décoché), startedAt = null, ne réapplique pas le préremplissage, et place le focus sur le champ « Nom et prénom ».
  • Le message de succès n'est pas annoncé deux fois : la prise de focus sur le titre suffit ; la région live n'est pas utilisée ici.

6.9.13 Sans JavaScript #

Décision : sans JavaScript, le formulaire n'existe pas (un formulaire sans action enverrait les données en clair dans l'URL). Le rendu serveur contient :

  • le bloc interactif du formulaire avec l'attribut hidden, hauteur réservée (min-height 640 px desktop, 720 px mobile) ; l'attribut est retiré au montage (mounted → true) ;
  • un bloc ContactFallback (classe .contact-fallback) masqué par défaut et affiché par @media (scripting: none) dans globals.css (aucun <noscript>, aucun style en ligne) : il contient le texte noscript.text, un lien WhatsApp (getBookingAction({}) calculé côté serveur), un lien mailto: vers site.contact.email et un lien tel: vers site.contact.phoneE164.

La carte WhatsApp de gauche fonctionne dans tous les cas (liens simples).

6.9.14 Interactions #

Souris : clic dans un champ, ouverture du select natif, clic sur le libellé de la case (toute la ligne), clic sur le lien de politique de confidentialité (nouvel onglet, le formulaire garde son état), clic sur le bouton d'envoi.

Tactile : zones de 44 px minimum partout ; claviers adaptés (type="email", inputMode="tel") ; selects natifs ; aucune interaction à deux doigts ni au survol ; le champ activé est amené en vue par le navigateur (marge de scroll-margin-top = hauteur d'en-tête + 16 px).

Clavier : ordre de tabulation = ordre visuel (Nom → E-mail → Téléphone → Offre → Situation → Message → Case → Politique de confidentialité → Bouton d'envoi). Le honeypot est exclu (tabIndex=-1). Entrée dans un champ texte soumet le formulaire ; dans le textarea elle insère un retour à la ligne. Les liens du récapitulatif d'erreurs sont atteignables par Tab. Échap n'a pas de rôle.

6.9.15 Accessibilité #

  • Chaque contrôle possède un <label for> visible ; aides et erreurs reliées par aria-describedby ; aria-invalid à l'erreur ; aria-required="true" sur les champs obligatoires ; l'attribut natif required n'est pas utilisé et le formulaire porte noValidate (la validation est celle du schéma partagé, pas celle du navigateur).
  • autocomplete selon WCAG 1.3.5 : name, email, tel ; off sur offre, situation, message et honeypot.
  • Message d'erreur identifiable en texte et par icône, jamais par la couleur seule ; contraste du token error sur ivoire ≥ 6:1.
  • Focus géré : récapitulatif (échec de validation), titre de bannière (échec serveur/réseau), titre de succès, champ « Nom » (nouveau message). Pendant l'envoi, le focus reste où il est (bouton aria-disabled, champs readOnly).
  • Ordre du DOM = ordre visuel : la carte WhatsApp précède le formulaire à tous les breakpoints.
  • État de chargement : aria-busy, texte du bouton, annonce submit.loadingAriaLive.
  • Accessible Authentication (WCAG 3.3.8) : aucun test cognitif ; aucun CAPTCHA.
  • Redundant Entry (WCAG 3.3.7) : les données saisies sont conservées en cas d'erreur.
  • Les cibles tel: et mailto: sont des liens avec texte explicite ; l'icône est décorative.
  • Zoom 200 % : une colonne, aucun contenu masqué.

6.9.16 Motion (voir §8) #

  • Entrée de section : révélation de l'en-tête, puis de la carte WhatsApp et du formulaire (stagger cartes 0,1 s), base 0,5 s, une fois.
  • Carte WhatsApp : bouton avec les classes .motion-press (appui 0,98) et .motion-arrow, neutralisées en mouvement réduit ; halo discret derrière la carte, ambiant : un seul cycle de 5 s au plus à chaque entrée dans le viewport (§8), en pause hors écran, désactivé sur mobile et en mouvement réduit.
  • Récapitulatif d'erreurs et bannière : apparition par fondu fast 0,25 s (aucune animation de hauteur ; le conteneur d'erreurs pousse le contenu : acceptable car déclenché par l'utilisateur).
  • Succès : fondu base 0,5 s de la confirmation ; icône de coche tracée par clip-path.
  • Mouvement réduit : tout apparaît instantanément ou en fondu ≤ 0,15 s ; la rotation du spinner est supprimée.

6.9.17 Analytics (voir §9.8) #

Les liens mesurés (bouton WhatsApp de la carte et des liens de secours) passent par le composant CtaLink (§9.8) ; aucun autre composant n'appelle track() dans cette section.

Action Événement Propriétés
Clic sur le bouton WhatsApp de la carte (et du bandeau de secours) cta_click cta: 'contact_whatsapp'
Fin d'une soumission réseau réussie (y compris abandon silencieux) contact_submit result: 'success'
Fin d'une soumission réseau échouée : tout SubmitOutcome final autre que success et aborted, y compris une réponse 422 renvoyée par le serveur contact_submit result: 'error'

Aucun autre événement : pas d'événement pour les liens tel: / mailto:, ni pour les erreurs de validation locales (aucune requête n'est partie), ni pour le préremplissage. Jamais de contenu de formulaire, d'identifiant d'offre, de situation, d'adresse e-mail ou de requestId. track() est sans effet avec ANALYTICS_PROVIDER=none.

6.9.18 Cas limites #

  1. Visiteur qui revient sur la page après un succès : le formulaire est neuf (aucun état conservé).
  2. Remplissage automatique du navigateur : valeurs lues depuis l'état contrôlé (onChange + onInput) ; un champ rempli automatiquement et non encore « touché » est validé à la soumission.
  3. Collage d'un message de plus de 2 000 caractères : maxLength tronque ; le compteur affiche 2000 / 2000.
  4. Plusieurs espaces ou message de 20 espaces : trim → message trop court.
  5. E-mail en majuscules : accepté (le serveur le met en minuscules).
  6. Téléphone avec indicatif +33 : accepté ; conservé tel quel.
  7. Délai de 30 s dépassé alors que le serveur a traité : un doublon est possible côté visiteur, mais le serveur déduplique (§9.4.6) ; le message affiché invite à réessayer ou à utiliser les liens de secours.
  8. Perte de connexion pendant l'envoi : offline ; à la reconnexion, l'événement online efface la bannière hors ligne (la saisie reste).
  9. Navigation arrière pendant l'envoi : la requête est abandonnée (abort) ; aucune erreur affichée.
  10. Honeypot rempli par un gestionnaire de mots de passe : le serveur ignore silencieusement (succès affiché) ; les attributs data-1p-ignore, data-lpignore et autocomplete="off" réduisent ce risque.
  11. Plusieurs onglets : chacun garde son état ; la limitation de débit serveur (§9.6) s'applique par adresse IP.
  12. Contenu enrichi / balises HTML dans le message : transmis tel quel, échappé côté serveur (§9.5) ; aucun HTML interprété à l'écran.
  13. Mauvais Origin (ex. accès par un domaine non autorisé) : FORBIDDEN_ORIGIN : la bannière oriente vers les liens de secours. En développement, ouvrir le site par l'adresse de SITE_URL (http://localhost:3000), et non par 127.0.0.1 ou l'adresse IP de la machine, sans quoi l'origine est refusée.
  14. prefers-reduced-motion actif : aucun spinner animé, transitions instantanées.
  15. Section sans JavaScript : voir §6.9.13.
  16. Chargement direct de /?offre=<id>#contact : l'offre est présélectionnée, puis la barre d'adresse devient /#contact ; un rechargement ne réapplique donc rien.
  17. Appel de requestContactPrefill avant le montage du formulaire : les valeurs en mémoire sont appliquées au montage.

6.9.19 Critères d'acceptation #

  • AC-6.9-01 — Mobile : la carte WhatsApp précède le formulaire ; desktop : carte en colonnes 1–5, formulaire en colonnes 6–12.
  • AC-6.9-02 — Aucune mention de délai de réponse n'apparaît dans la section (recherche de motifs « sous », « 24 h », « rapidement », « meilleurs délais » : zéro résultat) ; aucun texte du formulaire n'emploie « nous ».
  • AC-6.9-03 — Le bouton WhatsApp pointe vers https://wa.me/33667295179?text=… avec target="_blank" et rel="noopener noreferrer" ; tel:+33667295179 et mailto:astheoris.pro@gmail.com sont présents.
  • AC-6.9-04 — Chaque champ a un label visible, un autocomplete conforme au tableau 6.9.6, et un aria-describedby valide ; aucun placeholder ne porte d'information ; le select « situation » est visible et facultatif.
  • AC-6.9-05 — Soumettre le formulaire vide affiche un récapitulatif d'erreurs, déplace le focus sur son titre et marque name, email, message, consent en aria-invalid="true".
  • AC-6.9-06 — Un lien du récapitulatif place le focus sur le champ concerné.
  • AC-6.9-07 — Une erreur d'un champ modifié apparaît au blur et disparaît dès que la valeur est corrigée ; traverser un champ vide au clavier sans le modifier n'affiche aucune erreur.
  • AC-6.9-08 — Le honeypot est hors écran, aria-hidden, tabindex="-1", autocomplete="off", et n'est ni display:none ni type="hidden" ; axe n'y relève aucune violation.
  • AC-6.9-09 — startedAt est inclus dans la charge utile selon les règles de §9.4.8 (calibrage d'horloge, attente minimale) ; une soumission trop rapide est retardée, jamais abandonnée.
  • AC-6.9-10 — Pendant l'envoi : aria-busy="true" sur le formulaire, champs texte en readOnly, bouton en aria-disabled="true" avec le libellé submit.loadingLabel, aucun <fieldset disabled>, le focus reste sur le bouton ; un double-clic n'émet qu'une seule requête.
  • AC-6.9-11 — Succès (SubmitOutcome.status === 'success') : le formulaire est remplacé par la confirmation, le focus est sur son titre, success.resetLabel rétablit un formulaire vierge avec le focus sur « Nom ».
  • AC-6.9-12 — Pour chaque ligne du tableau 6.9.11 (400, 403, 405, 413, 415, 422, 429, 500, 502, hors connexion, réseau, délai dépassé), le message et l'action de récupération correspondent ; les données saisies sont conservées.
  • AC-6.9-13 — 429 avec Retry-After: 120 affiche retryAfter.minuteMany avec {n} = 2 et rend le bouton inactif ; il se réactive à l'échéance.
  • AC-6.9-14 — 502 affiche les liens WhatsApp et e-mail ; le lien mailto: contient le message de l'utilisateur si l'URL encodée ≤ 1 800 caractères.
  • AC-6.9-15 — Un appel à requestContactPrefill({ offerId: 'suivi-transition', situationId: 'fatigue' }) présélectionne ces deux options sans modifier l'adresse de la page ; le chargement direct de /?offre=suivi-transition#contact présélectionne l'offre puis réécrit l'adresse en /#contact ; une valeur inconnue est ignorée sans erreur ; situation placé dans une URL est ignoré.
  • AC-6.9-16 — Le champ « message » est vide après tout préremplissage.
  • AC-6.9-17 — Aucune donnée saisie n'est écrite dans localStorage, sessionStorage, cookie ou URL.
  • AC-6.9-18 — contact_submit est émis une seule fois par tentative réseau ; jamais pour une erreur de validation locale ; émis avec result: 'error' pour une réponse 422 du serveur ; aucune propriété ne contient de donnée saisie.
  • AC-6.9-19 — Aucune violation axe ; la navigation clavier complète le parcours (champs → envoi → confirmation) sans piège de focus.
  • AC-6.9-20 — Zone cliquable ≥ 44 × 44 px pour la case, le bouton d'envoi et les liens de coordonnées.
  • AC-6.9-21 — Un fetch suspendu au-delà de 30 s produit l'issue timeout ; avant cette échéance, l'interface reste en état d'envoi.
  • AC-6.9-22 — JavaScript désactivé (@media (scripting: none)) : aucun formulaire visible ; ContactFallback présente WhatsApp, e-mail et téléphone ; le HTML ne contient aucun <noscript> ni style en ligne.

6.10.1 Objectif #

Pied de page immersif : fond vert forêt, silhouette de montagnes, rivière lumineuse (décors entièrement vectoriels), mot géant et discret « CLARTÉ », carte d'appel à l'action flottante qui chevauche la jonction avec la section Contact, colonnes de navigation, bloc de contact, liens légaux, ligne de copyright à année dynamique, lien de retour en haut. Élément <footer> (landmark contentinfo), sans entrée dans la navigation principale. Rendu identique sur / ; variante compacte sur les pages légales et système (voir §6.10.9).

6.10.2 Mise en page par breakpoint #

Desktop (≥ 1024 px) :

      ┌── fin de la section Contact (ivoire) ───────────────────────────┐
      │                                       (≥ 64 px d'espace libre)   │
      │  ┌───────────────────────────────────────────────────────────┐  │
      │  │ CARTE CTA FLOTTANTE (ivory-50, ombre)                     │  │ ← chevauche la jonction
══════╪══│ titre (<p> display)             [ Écrire sur WhatsApp ]   │══╪══ jonction
 forêt│  │ texte court                     Écrire via le formulaire  │  │   Contact / footer
      │  └───────────────────────────────────────────────────────────┘  │
      │        ▲▲   montagnes (SVG, forest-800/700)  ▲▲▲                 │
      │   ~~~~ rivière lumineuse (SVG dégradé champagne → turquoise) ~~~ │
      │  Malka Debleds      Naviguer          Contact                    │
      │  tagline            Philosophie       Écrire sur WhatsApp        │
      │                     Méthode           06 67 29 51 79             │
      │                     …                 astheoris.pro@gmail.com    │
      │                                       France — à distance        │
      │ ──────────────────────────────────────────────────────────────── │
      │ Mentions légales · Confidentialité     © {année} Malka Debleds ↑ │
      │ C L A R T É   (géant, ivoire à 8 %, rogné par le bas du footer)  │
      └──────────────────────────────────────────────────────────────────┘

Grille interne : 12 colonnes ; marque 4 colonnes, « Naviguer » 3 colonnes, « Contact » 4 colonnes (1 colonne libre) ; barre basse sur toute la largeur.

Tablette (768–1023 px) : carte CTA pleine largeur du conteneur ; marque pleine largeur ; « Naviguer » et « Contact » en deux colonnes égales ; barre basse sur deux lignes (liens légaux, puis copyright et retour en haut).

Mobile (< 768 px) : tout empilé en une colonne (marque, navigation, contact) ; carte CTA pleine largeur (gouttière 24 px), bouton pleine largeur ; barre basse empilée ; retour en haut aligné à droite. Largeur minimale 320 px sans défilement horizontal.

6.10.3 Source de contenu #

  • Textes de la carte, titres de colonnes, libellés, copyright : src/content/fr/footer.ts (schéma §9.3.3, valeurs dans §7.12). Tagline : site.ts → tagline.
  • Coordonnées : site.ts → contact. Liens de navigation : navigation.ts (mêmes entrées que la navigation principale, voir §6.1), avec retrait de « Témoignages » si aucun témoignage n'est publiable (voir §6.8.2).
  • Liens légaux : footer.columns.legal.links (/mentions-legales, /politique-de-confidentialite).
  • Lien WhatsApp : getBookingAction({}), appelé côté serveur par FooterSection (voir §9) et transmis en props ; aucun îlot du footer n'importe lib/booking/index.ts.

6.10.4 Chevauchement de la carte flottante (décision exacte) #

Token --footer-overlap (défini en §5.11 et déclaré dans globals.css) : 48 px sous 768 px, 64 px de 768 à 1023 px, 80 px à partir de 1024 px. Le chevauchement est une constante : il ne dépend pas de la hauteur de la carte. La carte est position: relative, jamais fixed ni sticky : elle défile avec la page.

Règles :

  1. Le <footer> est un contexte d'empilement (position: relative; isolation: isolate; display: flow-root) sans overflow: hidden. Les décors (montagnes, rivière, mot géant) sont dans un enfant aria-hidden position:absolute; inset:0; overflow:hidden; z-index: var(--z-base) (clip interne), de sorte que la carte puisse dépasser du haut du footer.
  2. La carte (FloatingCtaCard) est le premier enfant de contenu du footer, position: relative; z-index: var(--z-content); margin-top: calc(var(--footer-overlap) * -1) : son bord supérieur se situe à --footer-overlap au-dessus de la jonction Contact → footer, le reste est dans le fond vert. (display: flow-root sur le footer empêche l'effondrement de la marge négative.)
  3. La section précédente (#contact) réserve la zone recouverte : padding-bottom: calc(var(--spacing-section) + var(--footer-overlap)). Comme --spacing-section vaut au moins 72 px (§5.6.1), il reste toujours au moins 64 px d'espace libre entre le dernier contenu de la section et le haut de la carte, quelle que soit la hauteur de la carte : aucune collision textuelle possible.
  4. Sous la carte, le footer ajoute margin-bottom: 96px (desktop) / 72px (tablette) / 56px (mobile) avant le premier bloc de contenu, puis les silhouettes décoratives passent derrière.
  5. La carte a un fond opaque ivory-50, un rayon de 20 px, l'ombre du token shadow-elev-4 (§5.7) et une bordure champagne-200 1 px ; elle masque donc entièrement les décors derrière elle (aucun texte superposé à une illustration).
  6. Quand la section précédente est absente (pages légales et système, voir §6.10.9), la carte n'est pas rendue et aucun padding de réserve n'est appliqué.

6.10.5 Décors : montagnes, rivière, mot géant #

Les décors du footer sont entièrement vectoriels (SVG inline) : aucun fichier image à fournir, aucune entrée au registre des licences. Tous sont aria-hidden="true", pointer-events: none, user-select: none, z-index: var(--z-base), et se trouvent sous tout contenu textuel.

  • Montagnes : SVG inline (viewBox="0 0 1440 360", preserveAspectRatio="xMidYMax slice"), deux plans (forest-800 à l'arrière, forest-700 à l'avant, bords nets) ancrés en bas de la zone décor, hauteur 220 px mobile / 300 px tablette / 360 px desktop. Les sommets ne dépassent jamais la frontière supérieure du footer (le clip interne les coupe).
  • Rivière lumineuse : SVG inline composé de trois éléments : (1) path#river-base : courbe en S qui descend entre les montagnes jusqu'au bas de page, stroke="url(#river-gradient)", stroke-width 6–14 px (largeur variable par deux tracés superposés), stroke-linecap="round", fill="none" ; (2) linearGradient#river-gradient : champagne-200 → champagne-400 → turquoise-400 ; (3) path#river-glow : même tracé, stroke blanc cassé (ivory-50), stroke-width 3 px, filtre feGaussianBlur stdDeviation="6" statique, opacité de base 0,35. L'animation shimmer (voir §8) fait varier uniquement l'opacity de #river-glow (0,35 → 0,75 → 0,35 : état final identique à l'état initial) en un seul cycle de 5 s au plus à chaque entrée dans le viewport (animation-iteration-count: 1) et le transform d'un masque dégradé : aucune animation de stroke-dashoffset. Rivière entièrement statique sur mobile (< 768 px) et sous prefers-reduced-motion.
  • Mot géant « CLARTÉ » (propriétaire des valeurs : §5.4.6) : texte réel dans le SVG ou un <span aria-hidden> en Fraunces graisse 600, font-size: clamp(6rem, 22vw, 20rem), line-height: 0.8, couleur ivory-50 à 8 % d'opacité (WORD_OPACITY.footer), letter-spacing: 0.04em, aligné en bas, rogné par le bas du footer (décalage translateY(12%)). Il est statique (aucune parallaxe) et passe derrière la barre basse ; tout texte qui le recouvre garde un contraste ≥ 7:1 (ivoire sur forest-950 légèrement éclairci). Il n'est jamais lu par les lecteurs d'écran.
  • Fond du footer : forest-950 avec dégradé vertical vers forest-900 à mi-hauteur ; grain fin optionnel (texture SVG ≤ 2 KB), désactivé sur mobile.

6.10.6 Arbre de composants et props #

<FooterSection variant="full" | "compact">  src/components/sections/footer/footer-section.tsx (server)
├─ <FooterRiver />                           src/components/sections/footer/footer-river.tsx (client) : décors (montagnes, rivière, mot géant), aria-hidden
├─ {variant === 'full' && <FloatingCtaCard …/>}   src/components/sections/footer/floating-cta-card.tsx (server ; liens suivis par CtaLink)
├─ <div class="footer-grid">
│  ├─ <FooterBrand name tagline />
│  ├─ <nav aria-label="Navigation du pied de page"> <FooterNavList links /> </nav>
│  └─ <FooterContact contact whatsappHref />
└─ <FooterBottomBar>
   ├─ <nav aria-label={footer.columns.legal.title}> <ul> mentions · confidentialité </ul> </nav>
   ├─ <p>© <CurrentYear /> Malka Debleds. Tous droits réservés.</p>
   └─ <a href="#haut"> Retour en haut ↑ </a>

FooterBrand, FooterNavList, FooterContact et FooterBottomBar sont des composants serveur définis dans footer-section.tsx. Il n'existe qu'un seul composant de carte flottante : FloatingCtaCard.

interface FooterSectionProps {
  variant: 'full' | 'compact';
  navLinks: { id: string; label: string; href: string }[];   // href de la forme '/#methode'
  contact: SiteContact;                                      // voir site.ts, §9.3
  whatsappHref: string;                                      // de getBookingAction({}), calculé côté serveur
  hasTestimonials: boolean;                                  // reflet du filtre de §6.8.2
  content: FooterContent;                                    // §9.3.3, textes en §7.12
}

interface FloatingCtaCardProps {
  title: string;                                             // rendu dans un <p> stylé display (pas un titre)
  text: string;
  primary: { label: string; href: string };                  // bouton WhatsApp : footer.ctaCard.primaryCta.label + getBookingAction({})
  secondary: { label: string; href: '/#contact' };           // lien formulaire : footer.ctaCard.secondaryCta.label
}

interface CurrentYearProps { initial: number }               // année de build

CurrentYear (client, src/components/sections/footer/current-year.tsx) : état initial = année du build (rendu serveur identique, aucun avertissement d'hydratation), puis useEffect(() => setYear(new Date().getFullYear()), []) : l'année affichée est celle du visiteur, jamais figée (pas de dépendance à un nouveau build au 1er janvier).

Les liens internes du footer utilisent next/link avec un href de la forme /#{id} : ils fonctionnent depuis / (défilement doux) et depuis les pages légales (navigation vers / puis ancre).

6.10.7 Contenu des colonnes et de la carte #

Carte CTA (variant full) : le titre est un <p class="font-display text-2xl"> (et non un titre h2) pour ne pas ajouter de niveau de titre hors plan (voir §6.10.8) ; texte court (footer.ctaCard.text) sans promesse ; bouton principal « Écrire sur WhatsApp » (pilule forest-800 / texte ivory-50, hauteur 56 px, target="_blank" rel="noopener noreferrer", texte masqué annonçant l'ouverture d'un nouvel onglet) ; lien secondaire texte « Écrire via le formulaire » vers /#contact (soulignement, zone de 44 px). Décision : le bouton principal est WhatsApp (le formulaire est juste au-dessus sur la page d'accueil et reste accessible depuis le lien secondaire sur les autres pages).

Colonne « Naviguer » : liens, dans l'ordre de la navigation principale : Philosophie · Méthode · Accompagnements · Orientation · À propos · Témoignages (seulement si hasTestimonials) · Contact. Aucun lien « Accueil » supplémentaire (la marque renvoie à /).

Colonne « Contact » : mêmes liens et libellés que la carte de §6.9.5 (WhatsApp texte « Écrire sur WhatsApp », tel: depuis site.contact.phoneE164, mailto: depuis site.contact.email, ligne de localisation site.contact.location sans lien).

Marque : mot-symbole « Malka Debleds » (Fraunces) + tagline (site.ts). Le mot-symbole est un lien vers /.

Barre basse :

  • Liens légaux : footer.columns.legal.links (/mentions-legales, /politique-de-confidentialite).
  • Copyright : footer.copyright avec {year} interpolé par CurrentYear.
  • « Retour en haut » : <a href="#haut"> (cible id="haut" de §4.2.1) avec icône ArrowUp (aria-hidden) ; fonctionne sans JavaScript ; défilement doux via CSS scroll-behavior: smooth sauf prefers-reduced-motion: reduce (saut direct).

6.10.8 États, interactions et accessibilité #

États :

État Rendu
Normal voir §6.10.2
Sans témoignage publiable colonne « Naviguer » sans « Témoignages »
Sans JavaScript tout fonctionne (liens simples) ; seuls le shimmer, la parallaxe et l'année dynamique (année de build) sont figés
Mouvement réduit décors statiques ; carte apparaît sans translation
Mobile rivière statique, grain désactivé, pas de parallaxe
Chargement / erreur / vide sans objet

Interactions :

  • Souris : survol des liens = soulignement champagne-400 en 1,5 px (transition fast) ; boutons de la carte : classes .motion-press (appui 0,98) et .motion-arrow pour la flèche, neutralisées en mouvement réduit (§8).
  • Tactile : zones de 44 px ; aucun effet de survol requis.
  • Clavier : ordre = carte CTA (bouton WhatsApp, lien formulaire) → marque → navigation → contact → liens légaux → retour en haut. Anneau de focus champagne-400 2 px + décalage 2 px (fond sombre) ; sur la carte claire, turquoise-600.

Accessibilité :

  • <footer> unique par page ; deux <nav> nommés (aria-label="Navigation du pied de page" et le libellé légal footer.columns.legal.title) ; les titres de colonnes sont des <p id> visibles, pas des titres h1–h6 ; chaque <nav> ou liste est étiquetée par aria-labelledby vers ce <p> ou par son aria-label (le plan de titres de la page reste inchangé).
  • Contraste : ivory-50 sur forest-950 ≥ 15:1 ; sage-200 (liens secondaires) ≥ 9:1 ; champagne-400 sur forêt ≥ 8:1.
  • Mot géant, montagnes, rivière : aria-hidden, aucun rôle d'image, aucun texte alternatif.
  • Cibles ≥ 44 px pour tous les liens du footer (padding vertical), séparées d'au moins 12 px (la zone étendue hit-area ne chevauche pas la voisine).
  • Le footer ne réutilise pas les ids de la navigation principale (pas de doublons d'id).
  • Le numéro de téléphone porte white-space: nowrap.

Motion (voir §8, qui possède les valeurs) :

  • Révélation progressive à l'entrée dans le viewport : montagnes (fondu slow 0,9 s, translation Y 24 → 0 px), rivière (fondu slow, décalé de 0,2 s), carte (fondu + translation Y 16 → 0 px, base 0,5 s), colonnes en cascade stagger cartes 0,1 s ; une seule fois.
  • Parallaxe : plan arrière des montagnes 24 px (footerFar), plan avant 12 px (footerMid) ; mot géant statique ; uniquement sur desktop (≥ 1024 px) avec pointeur fin et sans mouvement réduit. FooterRiver rend toujours des éléments m.* dont les MotionValue restent à 0 quand la parallaxe est désactivée : le basculement de profil ne remonte aucun arbre.
  • Shimmer de la rivière : un seul cycle (repos → pic → repos, état final identique à l'état initial) de 5 s au plus à chaque entrée dans le viewport, animation-iteration-count: 1, en pause quand document.hidden ; absent sur mobile et en mouvement réduit.
  • Mouvement réduit, mobile : statique, fondu simple ≤ 0,15 s.

Analytics (voir §9.8) : bouton WhatsApp de la carte → cta_click avec cta: 'footer_cta'. Le lien secondaire vers le formulaire, les liens de navigation, de contact et légaux n'émettent aucun événement.

6.10.9 Variante compacte (pages légales et système) #

variant="compact" : carte CTA non rendue, --footer-overlap ignoré, la section précédente n'a aucun padding de réserve, le reste (montagnes, rivière, mot géant, colonnes, barre basse) est identique. La navigation du footer conserve les liens vers /#{id}. La carte n'est pas affichée sur /mentions-legales, /politique-de-confidentialite, la page 404 et la page d'erreur.

6.10.10 Cas limites #

  1. Carte CTA très haute (texte agrandi 200 %) : le chevauchement reste constant (--footer-overlap) ; le reste de la carte s'étend dans le footer ; aucune collision avec #contact grâce à la réserve de padding-bottom.
  2. Contenu de #contact plus long que prévu : rien à changer, la réserve est calculée à partir du bas de la section.
  3. Très grand écran (≥ 1920 px) : montagnes en slice, mot géant plafonné à 20 rem, conteneur plafonné à 1280 px.
  4. Année : si l'horloge du visiteur est incorrecte, l'année affichée est celle de son système ; non bloquant.
  5. Impression : décors masqués (@media print), carte CTA masquée, liens affichés avec leur URL pour tel:, mailto:.
  6. Sans témoignage : la navigation reste cohérente (sept liens moins un).
  7. Si getBookingAction ne renvoie pas whatsapp (futur fournisseur) : le bouton principal de la carte suit l'action retournée (libellé et lien), sans changement de mise en page.
  8. Lecteur d'écran : le mot « CLARTÉ » n'est jamais lu ; seul le contenu utile l'est.
  9. Mode contraste élevé (forced-colors: active) : les décors sont masqués, la carte garde une bordure CanvasText.

6.10.11 Critères d'acceptation #

  • AC-6.10-01 — Le footer est un unique <footer> ; il contient deux <nav> nommés et aucun id dupliqué avec le reste de la page.
  • AC-6.10-02 — À 1440 px, le bord supérieur de la carte CTA se situe 80 px au-dessus du bord supérieur du footer ; 64 px entre 768 et 1023 px ; 48 px sous 768 px (mesure par getBoundingClientRect, valeurs lues dans le token --footer-overlap). La carte n'a jamais position: fixed ni position: sticky.
  • AC-6.10-03 — Il existe au moins 64 px d'espace libre entre le contenu de #contact et le haut de la carte à 320, 768, 1024, 1440 et 1920 px, y compris avec texte agrandi à 200 %.
  • AC-6.10-04 — Aucun élément textuel ne recouvre un autre (test visuel de non-chevauchement par boîtes englobantes sur les cinq largeurs).
  • AC-6.10-05 — Montagnes, rivière et mot « CLARTÉ » sont aria-hidden et non cliquables ; le mot est invisible aux lecteurs d'écran. Le décor est entièrement vectoriel : aucun fichier image n'est chargé pour le footer.
  • AC-6.10-06 — Le mot « CLARTÉ » du footer a une opacité de 8 % (ivory-50) et n'abaisse le contraste d'aucun texte sous 7:1.
  • AC-6.10-07 — L'année du copyright est celle de new Date().getFullYear() du navigateur (test avec horloge simulée à 2030).
  • AC-6.10-08 — Le lien « Retour en haut » (#haut) ramène en haut de page sans JavaScript et sans animation sous prefers-reduced-motion.
  • AC-6.10-09 — Le lien « Témoignages » est absent de la colonne de navigation quand aucun témoignage n'est publiable.
  • AC-6.10-10 — La carte contient un bouton principal « Écrire sur WhatsApp » (getBookingAction({})) et un lien secondaire « Écrire via le formulaire » vers /#contact ; le bouton principal émet cta_click footer_cta ; aucun autre élément du footer n'émet d'événement.
  • AC-6.10-11 — Le shimmer de la rivière exécute un seul cycle de 5 s au plus à chaque entrée dans le viewport, est en pause quand l'onglet est masqué, et est absent sous 768 px et sous prefers-reduced-motion.
  • AC-6.10-12 — Sur les pages légales, 404 et erreur, la carte CTA n'est pas rendue et aucun espace de réserve ne subsiste.
  • AC-6.10-13 — Tous les liens du footer ont une zone cliquable ≥ 44 px de haut et un anneau de focus visible.
  • AC-6.10-14 — Aucun défilement horizontal à 320 px ; le footer n'ajoute pas de CLS (> 0,01) à la page.
  • AC-6.10-15 — Le titre de la carte est un <p> ; le plan de titres de la page ne contient aucun titre issu du footer.

6.11 Pages légales et pages système #

6.11.1 Objectif et périmètre #

Cette section spécifie : la page /mentions-legales, la page /politique-de-confidentialite, la page 404 (not-found), la page d'erreur (error et global-error). Les contenus légaux proviennent de legal.ts ; aucune donnée légale n'est jamais inventée (identité, SIRET, adresse, hébergeur, durée de conservation des messages : fournis par Malka). Les paragraphes fixes (propriété intellectuelle, données personnelles et cookies, médiation, liens externes, sections de la politique de confidentialité) sont ceux de §7.13 ; cette section n'en redéfinit ni le libellé ni le contenu. Les garde-fous de build sont définis dans §9.3 et rappelés ici sans être redéfinis.

6.11.2 Mise en page éditoriale des pages légales #

Pages statiques, fond ivory-50, sans élément cinématographique. L'en-tête de site est en mode solide sur toutes les routes autres que / (voir §6.1). Footer en variante compact (voir §6.10.9).

┌──────────────────────────────────────────────────────────────────────┐
│ [ En-tête solide : Malka Debleds        nav (liens vers /#section) ] │
├──────────────────────────────────────────────────────────────────────┤
│   ← Retour à l'accueil                                               │
│   Mentions légales                         (h1, Fraunces, 3rem)      │
│   Dernière mise à jour : [lastUpdated formaté]                       │
│   ┌ Sommaire (privacy uniquement, ≥ 5 sections) ┐                    │
│   │ Responsable · Formulaire · Accusé · …      │                    │
│   └─────────────────────────────────────────────┘                    │
│   h2  Éditeur du site                                                │
│   Dl : raison sociale, forme juridique, SIRET, adresse, …            │
│   h2  Hébergeur …                                                    │
└──────────────────────────────────────────────────────────────────────┘
Breakpoint Règles
Desktop (≥ 1024 px) Colonne de lecture unique, largeur max 720 px, alignée à gauche dans le conteneur de 1280 px, marge gauche = colonnes 3–10 ; sommaire (privacy) en ligne, horizontal
Tablette (768–1023) Colonne 720 px centrée, gouttière 40 px
Mobile (< 768) Pleine largeur - gouttières 24 px ; sommaire en liste verticale

Typographie : h1 Fraunces 300, clamp(2.25rem, 1.6rem + 2.4vw, 3.5rem) ; h2 Fraunces 400, 1.5rem, marge haute 48 px ; corps Manrope 400, 1,0625 rem, interligne 1,7, ink-900 ; données d'identité en liste de définitions (<dl>, dt en Manrope 600 ink-600, dd ink-900). Padding vertical de page : token --spacing-section (py-section, §5.6.1) sous l'en-tête et avant le footer ; scroll-margin-top des titres = hauteur d'en-tête + 16 px.

Arbre :

<LegalPageShell>                          src/components/layout/legal-page-shell.tsx (server)
├─ <Link href="/"> ← Retour à l'accueil
├─ <h1>
├─ <p> <time dateTime={lastUpdated}>
├─ <DevBanner missing={string[]} />       (dev / staging seulement)
├─ {toc && <LegalToc sections />}
└─ <LegalSection id title> … </LegalSection> × n
interface LegalSectionDef {
  id: string;                              // ex. 'editeur'
  title: string;
  when?: (ctx: LegalContext) => boolean;   // rendu conditionnel (voir 6.11.4)
  render: (ctx: LegalContext) => ReactNode;
}
interface LegalContext {
  legal: LegalContent;                     // legal.ts
  appEnv: 'development' | 'staging' | 'production';
  analyticsProvider: 'none' | 'umami';
  umamiHost?: string;                      // hostname issu de UMAMI_SCRIPT_URL, jamais saisi à la main
  features: { contactForm: true; whatsapp: true; booking: 'whatsapp' };
}

Les textes fixes des pages légales sont dans §7.13 et sont marqués « proposition à valider par Malka » (le contenu des champs d'identité provient exclusivement de legal.ts).

Métadonnées : seo.routes.mentionsLegales / seo.routes.politiqueConfidentialite (§9.3.3, textes en §7.16), robots: index, follow en production (voir §11), présence dans sitemap.xml. Aucune donnée structurée, aucune image Open Graph spécifique (image par défaut).

6.11.3 Page « Mentions légales » : modèle de contenu #

Section (id) Champs de legal.ts utilisés Condition
Éditeur du site (editeur) publisher.legalName, publisher.tradeName, publisher.legalForm, publisher.siret, publisher.address, publisher.email, publisher.phone, publisher.publicationDirector toujours ; tradeName seulement si renseigné
Hébergeur (hebergeur) host.name, host.address, host.phone toujours
TVA (tva) vatMention toujours (texte fourni par Malka, jamais écrit par le développeur)
Propriété intellectuelle (propriete-intellectuelle) texte fixe de §7.13.1 toujours
Données personnelles et cookies (donnees) texte fixe de §7.13.1 + lien vers /politique-de-confidentialite toujours
Médiation de la consommation (mediation) mediator.name, mediator.url (lien externe rel="noopener noreferrer") ; texte fixe de §7.13.1 seulement si mediator est renseigné (obligatoire si Malka s'adresse à des consommateurs : confirmation B2B / B2C en §1.3)
Liens externes (liens-externes) texte fixe de §7.13.1 mentionnant WhatsApp comme service tiers toujours

Date : lastUpdated (YYYY-MM-DD) affichée en français (jour, mois en toutes lettres, année) via Intl.DateTimeFormat('fr-FR', { dateStyle:'long' }), dans un élément <time datetime>.

Pas de sommaire (la page est courte).

6.11.4 Page « Politique de confidentialité » : modèle de contenu #

Sommaire cliquable (liens d'ancre vers les id des sections), toujours présent (la page compte au moins neuf sections).

Sections, dans cet ordre, et conditions de rendu (le moteur getPrivacySections(ctx) filtre selon when). Le titre et le texte rédigé de chaque section sont ceux de §7.13.2 ; la colonne « Contenu normatif » fixe seulement ce que le texte doit établir. Les id sont ceux du tableau et ne changent pas.

# id Contenu normatif Rendu
1 responsable Identité du responsable du traitement : publisher.legalName (+ tradeName) ; adresse de contact pour toute demande : publisher.email toujours
2 formulaire Voir tableau ci-dessous toujours (le formulaire existe)
3 accuse-reception Le visiteur qui envoie le formulaire reçoit un accusé de réception automatique, au texte fixe, envoyé à l'adresse saisie par le prestataire d'e-mail ; cette adresse n'est retenue en mémoire du serveur que sous forme d'empreinte salée non réversible, pendant 24 heures, pour plafonner le nombre d'accusés ; fondement : intérêt légitime (art. 6.1.f du RGPD) toujours
4 whatsapp Les boutons « Écrire sur WhatsApp » ouvrent WhatsApp (service édité par Meta), soumis à ses propres conditions et à sa propre politique de confidentialité ; le site n'envoie aucune donnée à WhatsApp avant que le visiteur ne lance lui-même la conversation ; le message prérempli est visible et modifiable avant envoi ; les transferts éventuels hors Union européenne relèvent de la politique de WhatsApp toujours
5 boussole Le choix de situation de la boussole d'orientation est traité uniquement dans le navigateur, n'est ni enregistré ni transmis ni mesuré ; la situation n'est transmise que si le visiteur l'envoie lui-même, via le champ visible du formulaire ou dans un message WhatsApp toujours
6 cookies Aucun cookie ni traceur publicitaire ou de mesure ; aucune bannière de consentement ; aucune donnée dans localStorage ni sessionStorage, à une seule exception : lorsque la mesure d'audience est activée, l'indicateur d'opposition umami.disabled, écrit uniquement si le visiteur utilise lui-même le bouton d'opposition toujours
7 audience Outil Umami, sans cookie, sans donnée personnelle, respect du signal « Ne pas suivre », données agrégées de fréquentation et de clics sur les boutons ; hébergement sur l'instance {umamiHost} ; l'adresse de page transmise exclut la requête et le fragment ; aucune information issue de la boussole ou du formulaire n'est mesurée ; bouton d'opposition (§10.6.4) uniquement si analyticsProvider === 'umami'
8 droits Accès, rectification, effacement, limitation, opposition, portabilité ; exercice par e-mail à publisher.email ; réponse dans le délai légal d'un mois du RGPD toujours
9 reclamation Droit d'introduire une réclamation auprès de la CNIL ; lien https://www.cnil.fr/fr/plaintes (externe, rel="noopener noreferrer") toujours
10 mise-a-jour Date lastUpdated toujours

Il n'existe pas de section de réservation en ligne : aucun outil de rendez-vous n'est branché. L'ajout futur d'un fournisseur de réservation impose d'ajouter la section correspondante avant la mise en production (test E2E de cohérence en §15).

Détail de la section 2 « Formulaire de contact » (tableau structuré en <dl> ou table à th de ligne) :

Rubrique Contenu normatif
Finalité Répondre aux demandes de contact et de rendez-vous envoyées via le formulaire ; préparer, le cas échéant, un accompagnement
Base légale Mesures précontractuelles prises à la demande de la personne concernée (art. 6.1.b du RGPD) ; intérêt légitime (art. 6.1.f du RGPD) pour l'accusé de réception et les journaux de sécurité. La case à cocher du formulaire atteste de la lecture de l'information ; elle n'est pas la base légale du traitement
Données traitées Nom, adresse e-mail, numéro de téléphone (facultatif), message, offre et situation indiquées (facultatives) ; l'adresse IP est utilisée de façon transitoire en mémoire pour limiter les abus et n'apparaît dans les journaux techniques que sous forme d'empreinte tronquée non réversible
Destinataire Malka Debleds, par e-mail ; l'envoi est réalisé par Brevo, prestataire de messagerie transactionnelle (société française, données hébergées dans l'Union européenne), agissant comme sous-traitant
Conservation Le site ne conserve aucun message (pas de base de données) ; les messages restent dans la messagerie de Malka Debleds. La durée de conservation affichée est exactement la valeur du champ requis privacy.retention de legal.ts, fournie par Malka ; aucune durée par défaut n'est livrée (§1.3 indique la recommandation de la CNIL à titre de repère pour Malka). Les journaux techniques (sans contenu des messages) sont limités en taille et renouvelés automatiquement
Transfert hors UE Aucun transfert hors Union européenne n'est opéré par le site lui-même
Caractère obligatoire Nom, e-mail et message sont nécessaires pour répondre ; sans eux la demande ne peut pas être traitée
Décision automatisée Aucune

Règles de rédaction : textes en français clair, vouvoiement, Malka désignée à la troisième personne, phrases courtes ; aucune allégation non vérifiable ; aucune durée n'est écrite dans le texte fixe (elle vient de legal.ts) ; le nom du fournisseur de messagerie de Malka n'est jamais inféré de son adresse e-mail ; la page parle de « la messagerie de Malka Debleds ».

Cohérence avec la configuration : le moteur lit les sections à partir du contexte de build (ANALYTICS_PROVIDER, UMAMI_SCRIPT_URL) ; la section audience n'existe dans aucune page générée quand l'analytics est désactivé.

6.11.5 Données manquantes : bandeau de développement et garde de production #

La garde de build est définie dans §9.3 : avec APP_ENV=production, pnpm build échoue si un champ légal requis (y compris privacy.retention) est vide, en listant les champs manquants. Cette section ne la redéfinit pas ; elle spécifie le rendu hors production.

  • Avec APP_ENV à development ou staging, toute valeur de legal.ts vide est remplacée dans la page par un repère <mark class="dev-missing">donnée manquante</mark> (fond champagne-200, texte ink-900, bordure pointillée champagne-600).
  • Un bandeau DevBanner (role="note", fond champagne-200, bordure 2 px champagne-600, icône CircleAlert, texte ink-900 ≥ 7:1) est affiché en haut de la page, sous le h1 ; son texte est devPlaceholderBanner de §7.13.1. Il énumère la liste des champs manquants (noms de clés de legal.ts).
  • Ces repères n'existent pas dans le HTML de production (le build échoue avant). En seconde ligne de défense, un helper assertLegalComplete(ctx) appelé au rendu lève une erreur si appEnv === 'production' et qu'un champ est vide (la génération statique échoue).
  • Le bandeau n'est jamais indexé : en développement et staging, toutes les pages portent noindex (voir §11).
  • Si mediator est absent : la section mediation n'est pas rendue et ne génère aucun repère (champ optionnel).

6.11.6 Page 404 #

Fichier src/app/not-found.tsx (server). Réponse HTTP 404 (comportement Next.js pour une route inconnue). Métadonnées : seo.routes.notFound (§9.3.3), robots: noindex.

┌──────────────────────────────────────────────────────────────────────┐
│ [ En-tête (voir §6.1, mode solide) ]                                 │
│ fond forest-950, brume et montagnes en dégradé statique              │
│                                                                      │
│        404           (Fraunces, champagne-400 à 40 %, 10 rem)        │
│        [ h1 : titre de la page 404, §7.14 ]       (ivory-50)         │
│        [ phrase d'aide, §7.14 ]                                      │
│        [ bouton principal → / ]   [ bouton secondaire → /#contact ]  │
│                                                                      │
│ [ Footer compact ]                                                   │
└──────────────────────────────────────────────────────────────────────┘
  • Structure : <main id="contenu"> ; h1 unique ; le « 404 » décoratif est aria-hidden.
  • Boutons : retour à l'accueil (principal, href="/") et accès au contact (secondaire, href="/#contact"). Leurs libellés, comme tous les textes de la page, sont ceux de §7.14.
  • Mobile : « 404 » 6 rem, boutons empilés pleine largeur ; desktop : boutons côte à côte.
  • Aucune requête externe, aucune image lourde ; fond en CSS (dégradé radial + silhouette SVG statique ≤ 3 KB).
  • Aucune mention technique ; ton sobre.
  • Motion : fondu d'entrée base 0,5 s (instantané en mouvement réduit).
  • Analytics : aucun événement.

6.11.7 Page d'erreur #

Deux fichiers :

  • src/app/error.tsx (client, 'use client') : boundary de segment, rendu à l'intérieur du layout racine (l'en-tête et le footer restent affichés). Props : { error: Error & { digest?: string }; reset: () => void }.
  • src/app/global-error.tsx (client) : dernier recours quand le layout racine échoue. Il rend lui-même <html lang="fr"><body> avec des styles en ligne minimaux ; les couleurs (fond, texte, bouton) sont les constantes ERROR_PAGE_COLORS = { bg, fg, accent } importées de src/lib/design/tokens.ts (§5.1.3) : aucune couleur littérale n'est écrite dans global-error.tsx. Police Georgia, serif, bouton accent sur bg ; aucune dépendance à next/font, à Tailwind ni à un composant partagé.
interface ErrorPageProps {
  error: Error & { digest?: string };
  reset: () => void;
}

Contenu (textes de §7.14, écrits directement dans les composants) : un pictogramme décoratif TriangleAlert (§5.8, aria-hidden), un h1 et une phrase d'explication ; boutons « Réessayer » (appelle reset()) et retour à l'accueil (href="/") ; un bloc de secours invitant à joindre Malka directement, avec liens WhatsApp, e-mail et téléphone ; si error.digest existe, ligne discrète de référence ; ne jamais afficher error.message ni la pile.

Origine des liens de secours : error.tsx est un îlot client et n'importe ni contenu ni lib/booking/index.ts (module serveur uniquement). Le layout racine (serveur) calcule { whatsappHref, mailtoHref, telHref } (getBookingAction({}) et site.contact) et les transmet au composant Providers, qui les expose par un contexte React ; error.tsx les lit dans ce contexte. global-error.tsx, rendu quand le layout lui-même a échoué, n'y a pas accès : il n'affiche que « Réessayer » et le lien vers /.

Comportement :

  • Au montage, le focus est placé sur le h1 (tabindex="-1") car le contenu a été remplacé.
  • useEffect : aucune journalisation client ni envoi réseau (la journalisation est côté serveur, §9.7).
  • « Réessayer » : reset() ; si le rendu échoue encore, la boundary réapparaît (aucune boucle automatique).
  • Pas d'information technique au visiteur ; noindex.
  • Mouvement réduit : aucune animation ; fond statique.
  • Analytics : aucun événement ; les liens de secours WhatsApp n'émettent pas d'événement ici.

6.11.8 Cas limites #

  1. Pages légales et JavaScript désactivé : pages entièrement lisibles (contenu rendu côté serveur) ; seule l'année du footer reste celle du build.
  2. legal.ts vide en développement : toutes les pages légales s'affichent avec bandeau et repères, sans planter.
  3. vatMention ou privacy.retention vide : en développement, repère « donnée manquante » ; en production, build en échec (§9.3).
  4. mediator renseigné sans url : refusé au build (la paire nom + url est requise).
  5. Données avec caractères spéciaux ou balises : échappées par React, aucun HTML interprété.
  6. URL inconnue sous /api/… : réponse gérée par la route API (JSON, §9.4), pas par not-found.
  7. Erreur de rendu dans le layout racine : global-error.tsx s'affiche avec une mise en page minimale mais lisible, colorée par ERROR_PAGE_COLORS, et deux liens de sortie (réessayer, accueil).
  8. Navigation depuis une page légale vers /#methode : navigation vers la page d'accueil puis défilement vers l'ancre, avec compensation de l'en-tête.
  9. Changement de ANALYTICS_PROVIDER entre deux builds : la section audience apparaît ou disparaît sans autre modification.
  10. Impression des pages légales : en-tête, footer et boutons masqués ; contenu en noir sur blanc.
  11. Lien d'ancre vers une section de la politique (/politique-de-confidentialite#droits) : le défilement tient compte de l'en-tête (scroll-margin-top) et le focus se place sur le titre de la section.

6.11.9 Critères d'acceptation #

  • AC-6.11-01 — /mentions-legales et /politique-de-confidentialite répondent 200, sont générées statiquement et ont chacune un unique h1.
  • AC-6.11-02 — Avec les valeurs complètes dans legal.ts, aucune mention « donnée manquante » ni bandeau n'apparaît ; chaque champ de legal.ts listé en 6.11.3 et 6.11.4 est affiché.
  • AC-6.11-03 — Avec APP_ENV=development et un champ vide, le bandeau de développement est visible et liste le champ ; avec APP_ENV=production, pnpm build échoue (garde de §9.3).
  • AC-6.11-04 — La section audience est absente du DOM avec ANALYTICS_PROVIDER=none et présente avec umami (contenant le nom d'hôte de UMAMI_SCRIPT_URL et le bouton d'opposition de §10.6.4).
  • AC-6.11-05 — La section formulaire contient finalité, base légale (art. 6.1.b et 6.1.f ; la case à cocher n'y est pas présentée comme base légale), données, destinataire Brevo (UE) et durée de conservation égale, caractère pour caractère, à legal.privacy.retention ; aucune durée n'est écrite en dur dans le composant ni dans le texte fixe.
  • AC-6.11-06 — La politique de confidentialité comporte exactement les id responsable, formulaire, accuse-reception, whatsapp, boussole, cookies, audience (conditionnelle), droits, reclamation, mise-a-jour, dans cet ordre ; elle affirme l'absence de cookie, ne mentionne aucun outil non activé et indique que le seul élément de stockage local possible est l'indicateur d'opposition umami.disabled écrit sur demande explicite du visiteur.
  • AC-6.11-07 — La section whatsapp désigne WhatsApp comme service édité par Meta, soumis à sa propre politique ; la section reclamation contient le lien https://www.cnil.fr/fr/plaintes avec rel="noopener noreferrer".
  • AC-6.11-08 — Les pages légales utilisent l'en-tête solide, le footer compact (sans carte CTA) et une colonne de lecture ≤ 720 px ; aucun défilement horizontal à 320 px.
  • AC-6.11-09 — Une URL inconnue renvoie un statut 404 et affiche la page 404 avec un h1, un lien vers /, un lien vers /#contact ; la page porte noindex.
  • AC-6.11-10 — Une erreur de rendu simulée affiche la page d'erreur ; « Réessayer » appelle reset() ; le focus est sur le h1 ; ni error.message ni la pile ne sont visibles ; le bloc de secours expose les liens WhatsApp, e-mail et téléphone fournis par le layout.
  • AC-6.11-11 — global-error.tsx rend <html lang="fr"> avec styles en ligne, sans dépendance à next/font ni à Tailwind, et importe ses couleurs de ERROR_PAGE_COLORS (src/lib/design/tokens.ts) : le fichier ne contient aucune couleur littérale (le test de §5.1.3 passe).
  • AC-6.11-12 — Aucune violation axe sur les quatre pages ; contraste du bandeau de développement ≥ 7:1.
  • AC-6.11-13 — Aucune valeur d'identité légale (SIRET, adresse, hébergeur, durée de conservation) n'apparaît dans le code source en dehors de src/content/fr/legal.ts.
  • AC-6.11-14 — Les pages légales et système ne génèrent aucun événement d'analytics.

7. Contenus éditoriaux (copy deck) #

Cette section livre, pour chaque fichier de contenu, le texte français prêt à copier-coller. Elle fixe aussi les règles de ton et de typographie que tout texte ajouté plus tard doit respecter. Sauf mention contraire, chaque chaîne est en français, au vouvoiement, sans emoji.

7.1 Voix, ton et règles typographiques #

7.1.1 Principes de voix #

  • Vouvoiement systématique. Aucune exception, y compris dans les e-mails et les messages d'erreur.
  • Sobriété. Aucune surenchère, aucun superlatif commercial (« incroyable », « unique au monde », « révolutionnaire »). Les chiffres utilisés sont exclusivement 300+ et 50+ (voir §7.4) ; aucun autre chiffre n'est inventé.
  • Clarté avant tout. Phrases courtes dans les zones de décision (hero, boutons, erreurs). Phrases plus amples autorisées dans les zones éditoriales (philosophie, à propos).
  • Jargon dosé. La formule de positionnement « accompagnement vibratoire et stratégique des entrepreneurs » est conservée telle quelle là où elle apparaît (voir §7.4, §7.9), mais n'est pas répétée en boucle : le reste du site reste dans un vocabulaire concret et accessible.
  • Aucune promesse de résultat, aucun vocabulaire thérapeutique ou médical. Pas de « guérir », « soigner », « diagnostic », « traitement », « garanti », « vous allez réussir ». Le vocabulaire est celui du cheminement : avancer, clarifier, choisir, repartir avec un cap — jamais celui de la cure ou de la garantie.
  • Verbes concrets, voix active. On évite les tournures abstraites (« synergie », « disruption », « scaler ») et les tournures passives inutiles.
  • Malka parle à la première personne uniquement dans les e-mails et dans les contenus signés (§7.9, §7.15) ; ailleurs le texte reste descriptif, à la troisième personne ou impersonnel.
  • Aucun délai de réponse annoncé. Ni « sous 24 h », ni « rapidement », ni « dans les meilleurs délais » : le site n'engage Malka sur aucun délai, à l'écrit comme dans les e-mails.
  • Écriture épicène, sans point médian. Les formulations évitent la marque · (ex. « entendu·e ») ; on choisit un mot épicène ou une tournure neutre (« vous êtes entendu », « plutôt que traversées en solitaire »).

7.1.2 Exemples « à faire » / « à éviter » #

À faire À éviter Pourquoi
« Une écoute profonde, une lecture claire et des actions concrètes. » « La méthode ultime pour transformer votre business en 7 jours ! » Pas de promesse, pas de superlatif, pas de point d'exclamation commercial.
« Vous repartez avec une direction plus claire. » « Vous allez enfin réussir et doubler votre chiffre d'affaires. » Pas de promesse de résultat chiffré ou garanti.
« Un espace pour nommer ce qui bloque. » « Je soigne les blocages énergétiques de votre entreprise. » Pas de vocabulaire thérapeutique/médical.
« Quatre temps pour avancer avec discernement. » « La méthode secrète que personne ne vous a jamais révélée. » Pas de mystification marketing.
« Une proposition sur mesure, construite avec vous. » « Un accompagnement 100 % garanti et personnalisé comme nul autre. » Pas de superlatif absolu, pas de garantie.

7.1.3 Règles typographiques françaises #

Ces règles s'appliquent à toute chaîne visible par un visiteur (interface, e-mails, pages légales). L'espacement fin devant la ponctuation haute et à l'intérieur des guillemets n'est jamais tapé à la main dans les fichiers de contenu : il est produit automatiquement, au chargement du contenu, par l'assistant de typographie française frTypo (§5.5.6). Les fichiers src/content/fr/*.ts sont donc rédigés avec des espaces normales uniquement ; les règles 1 et 2 ci-dessous décrivent le résultat visible à l'écran, pas la façon de taper le texte source.

  1. Espace fine insécable (U+202F) avant ;, !, ?, et espace insécable (U+00A0) avant : : produites par frTypo à partir d'une espace normale tapée dans la source. Exemple de source : Prêt ? ; rendu à l'écran : « Prêt ? » (espace fine insécable).
  2. Guillemets français « … » : la source contient une espace normale après le guillemet ouvrant et avant le guillemet fermant (« Entreprise vivante », jamais "Entreprise vivante" ni «Entreprise vivante») ; frTypo la convertit en espace fine insécable à l'affichage.
  3. Apostrophe typographique ’ uniquement (jamais l'apostrophe droite '). Exemple : « aujourd’hui », « j’accepte ». Tapée directement dans la source ; frTypo la corrige par défaut si elle est oubliée, sauf pour la citation verbatim d'un témoignage.
  4. Prix : jamais tapés en dur dans un fichier de contenu — toujours produits par Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }), qui insère nativement le séparateur de milliers et l'espace avant € déjà insécables (voir §7.7).
  5. Numéros de téléphone : espace normale entre les groupes de chiffres dans la source (« 06 67 29 51 79 ») ; le retour à la ligne au milieu du numéro est empêché par white-space: nowrap en CSS, jamais par un caractère d'espacement spécial dans la chaîne.
  6. Points de suspension : le caractère unique …, jamais trois points .... Usage rare, réservé aux états de chargement (« Envoi en cours… »).
  7. Majuscules : aucune mise en MAJUSCULES dans le texte source pour un effet visuel (cela reste une responsabilité CSS, voir §5) — exception : le mot-marque « CLARTÉ » qui est décoratif et masqué aux technologies d'assistance (voir §7.4).
  8. Pas d'espace avant , et . — règle générale déjà respectée par défaut, rappelée ici pour éviter toute régression lors de futures modifications.

AC-7.1-01 : toute chaîne visiteur ajoutée ou modifiée dans src/content/fr/*.ts respecte les huit règles ci-dessus ; une revue de contenu avant mise en production vérifie l'absence d'apostrophe droite ' et de guillemets droits " dans les chaînes françaises. AC-7.1-02 : aucune chaîne visiteur ne contient les mots interdits « garanti », « guérir », « soigner », « diagnostic médical », « thérapie » (recherche automatisable en revue de contenu). AC-7.1-03 : aucune chaîne de src/content/fr/*.ts ne contient de caractère U+00A0 ou U+202F écrit en dur — ces espacements sont produits par frTypo au chargement (§5.5.6), jamais dans la source. AC-7.1-04 : aucune chaîne de src/content/fr/*.ts ne contient le point médian · (U+00B7) utilisé comme marque d'écriture inclusive ; les formulations retenues sont épicènes (voir §7.1.1).

7.2 Statut de chaque texte #

Trois statuts, un seul par texte :

  • FIXE — texte imposé, à retranscrire sans le modifier (titres figés, CTA, chiffres de confiance, titres et prix des offres, coordonnées de contact).
  • PROPOSITION — texte rédigé dans cette section, à valider par Malka avant mise en production (registre content-validation.json, clé copyDeck — garde G-05, voir §9.3.7) ; une fois validé, Malka peut l'ajuster dans le fichier de contenu correspondant sans intervention technique.
  • FOURNI PAR MALKA — emplacement (slot) que le texte ne peut pas deviner : biographie, citations de témoignages, identité légale. La spécification définit le champ, son format, sa validation et le garde-fou de build ; jamais la valeur elle-même.
Fichier de contenu Statut dominant Détail
site.ts Mixte Nom de marque et chiffres de confiance (valeur, libellé) sont FIXE (voir §7.4) ; accroche (« tagline ») et libellés d'accessibilité des chiffres sont PROPOSITION à valider par Malka.
navigation.ts PROPOSITION Libellés d'ancres et CTA « Faire le point » sont FIXE (voir §7.3) ; lien retour à l'accueil, lien d'évitement et libellés de menu mobile sont PROPOSITION à valider par Malka.
hero.ts Mixte Titre, description, CTA, chiffres de confiance et libellés Écouter/Explorer/Clarifier/Décider sont FIXE ; accroche (« eyebrow »), découpage des lignes du titre et libellés d'accessibilité sont PROPOSITION à valider par Malka.
philosophy.ts PROPOSITION Le mot-marque « Entreprise vivante » est FIXE ; titre, chapeau, corps, citation et principes sont PROPOSITION à valider par Malka.
method.ts Mixte Libellés Écouter/Explorer/Clarifier/Décider sont FIXE ; titre de section, accroches, explications, détails et bénéfices sont PROPOSITION à valider par Malka.
offers.ts Mixte Titres et prix des quatre offres sont FIXE ; la mention de format « À distance » est FIXE ; descriptions, détails, libellés de CTA et messages WhatsApp sont PROPOSITION à valider par Malka.
orientation.ts Mixte Les quatre libellés de situation (Décision bloquée, Fatigue, Repositionnement, Transition) sont FIXE ; pistes de réflexion, questions, phrase d'offre suggérée, désamorçage et messages WhatsApp sont PROPOSITION à valider par Malka.
about.ts Mixte Titre, posture professionnelle et approche humaine sont PROPOSITION à valider par Malka ; les paragraphes biographiques sont FOURNI PAR MALKA.
testimonials.ts Mixte Noms d'auteurs « Stéphanie » / « Steff Beauté Plurielle » / « Émilie » sont FIXE ; titre de section, intitulés du carrousel et note de vérification sont PROPOSITION à valider par Malka ; le texte même des citations est FOURNI PAR MALKA.
contact.ts Mixte Téléphone et e-mail sont FIXE ; tous les libellés de champs, messages de validation et d'erreur sont PROPOSITION à valider par Malka.
footer.ts Mixte Coordonnées sont FIXE ; carte CTA, titres de colonnes et mention de copyright sont PROPOSITION à valider par Malka.
legal.ts Mixte Structure, paragraphes explicatifs et rubriques fixes (propriété intellectuelle, données personnelles et cookies, médiation, liens externes) sont PROPOSITION à valider par Malka ; identité légale (SIRET, forme juridique, adresse, hébergeur, directeur de publication, mention TVA, durée de conservation) est FOURNI PAR MALKA.
seo.ts PROPOSITION Entièrement rédigé ici, à valider par Malka.
E-mails (src/lib/email/templates.ts) Mixte Coordonnées (téléphone, e-mail) sont FIXE ; corps des messages est PROPOSITION à valider par Malka.
Pages système (404, erreur) PROPOSITION Entièrement rédigées ici.

AC-7.2-01 : aucune valeur FOURNI PAR MALKA n'apparaît en dur dans le code ou dans un fichier de contenu livré ; chaque slot est soit vide avec garde-fou de build (production), soit remplacé par un texte de remplacement visible uniquement en développement/préproduction (jamais en production). AC-7.2-02 : en production, tout texte de statut PROPOSITION reste bloquant (garde G-05, §9.3.7) tant que la clé copyDeck du registre content-validation.json n'a pas validatedBy renseigné et validatedOn valide ; le texte biographique reste bloquant tant que la clé biography n'est pas validée.

Règle d'assemblage : chaque bloc ts de §7 est le contenu exact du fichier indiqué en tête de sous-section, à l'exception des commentaires // FIXE|PROPOSITION|FOURNI PAR MALKA, qui ne sont pas à recopier dans les chaînes. Les littéraux sont écrits satisfies ContentInput['<clé>'] (§9.3.3) et exportés sous un nom court (export const <clé> = {…}, ex. offers, orientation, testimonials, contact, footer), jamais avec un suffixe Content : l'agrégat de §9.3.4 importe { site, navigation, hero, … } sous ces noms.

AC-7.2-03 : getContent('fr') réussit sur les fichiers de contenu livrés (test « chaque fichier de contenu livré s'analyse » de §15).

7.3 Navigation, lien d'évitement et menu mobile #

Fichier : src/content/fr/navigation.ts.

export const navigation = {
  skipLink: 'Aller au contenu', // PROPOSITION
  links: [
    { id: 'philosophie', label: 'Philosophie' },
    { id: 'methode', label: 'Méthode' },
    { id: 'accompagnements', label: 'Accompagnements' },
    { id: 'orientation', label: 'Orientation' },
    { id: 'a-propos', label: 'À propos' },
    { id: 'temoignages', label: 'Témoignages', requires: 'publishableTestimonials' },
    { id: 'contact', label: 'Contact' },
  ], // libellés PROPOSITION ; ordre et ids FIXE (voir §4.2.1) ; `href` dérivé de `#${id}`, non stocké ici
  cta: {
    label: 'Faire le point', // FIXE
    targetId: 'contact',
  },
  menu: {
    openLabel: 'Ouvrir le menu', // PROPOSITION — aria-label du bouton fermé
    closeLabel: 'Fermer le menu', // PROPOSITION — aria-label du bouton ouvert
    dialogLabel: 'Menu principal', // PROPOSITION — nom accessible du panneau (role="dialog")
    navLabel: 'Navigation principale', // PROPOSITION — aria-label de la liste de liens à l’intérieur du panneau
  },
} as const;

Notes d'implémentation :

  • Le logo (image SVG optionnelle fournie par Malka, voir §1 et §6.1.3) n'a pas de texte propre dans ce fichier : son lien accessible est composé par le composant à partir de site.brandName (§9.3.3), par exemple « Malka Debleds — retour à l'accueil ».
  • La valeur de l'attribut aria-current portée par le lien actif ("location") est une constante de code, pas une chaîne de contenu (voir §4.2).

Règles d'accessibilité et d'état du menu mobile :

  • Le bouton hamburger porte aria-expanded (false/true) et aria-controls="menu-mobile" ; son libellé accessible change entre openLabel et closeLabel, l'icône seule n'est jamais le seul indicateur.
  • Le panneau mobile intercepte la touche Échap pour se fermer et replace le focus sur le bouton hamburger ; un clic sur le fond ne ferme pas le panneau.
  • Le lien actif (section visible au scroll) reçoit aria-current="location" ; un seul lien actif à la fois, y compris en mode clavier (navigation par ancre).
  • Le lien « Témoignages » et son item de menu ne sont ni rendus ni présents dans le DOM lorsque aucun témoignage n'est publiable (voir §6.8/§9.3) — pas de lien grisé, absence totale.
  • Logo cliquable : sur /, scroll vers #accueil ; sur les pages légales (§7.13) et les pages système (§7.14), lien classique vers /.

AC-7.3-01 : le lien d'évitement « Aller au contenu » est le premier élément focusable de la page et cible l'élément <main id="contenu">. AC-7.3-02 : le menu mobile est entièrement utilisable au clavier (Tab, Échap) et par lecteur d'écran (libellés dynamiques ci-dessus).

7.4 Hero « CLARTÉ » #

Fichier : src/content/fr/hero.ts.

export const hero = {
  eyebrow: 'Accompagnement vibratoire et stratégique des entrepreneurs', // PROPOSITION — à valider par Malka
  headlineLines: [
    'Retrouver la clarté',
    'pour faire grandir',
    'une entreprise alignée.',
  ], // PROPOSITION — découpage en 3 lignes pour la révélation ligne par ligne (voir §8) ; mises bout à bout dans cet ordre, les lignes reproduisent le titre FIXE « Retrouver la clarté pour faire grandir une entreprise alignée. », jamais reformulé
  description: 'Une écoute profonde, une lecture claire et des actions concrètes pour avancer avec plus de sens, de cohérence et de justesse.', // FIXE
  primaryCta: { label: 'Prendre rendez-vous', targetId: 'contact' }, // FIXE
  secondaryCta: { label: 'Découvrir la méthode', targetId: 'methode' }, // FIXE
  bigWord: 'CLARTÉ', // FIXE — mot-marque décoratif, masqué aux technologies d’assistance (aria-hidden, purement visuel, voir §8)
  railLabel: 'Les quatre temps de la méthode', // PROPOSITION
  images: {
    far: { src: '/images/hero/hero-far.webp', alt: '' },
    mid: { src: '/images/hero/hero-mid.webp', alt: '' },
    near: { src: '/images/hero/hero-near.webp', alt: '' },
    mobile: { src: '/images/hero/hero-mobile.webp', alt: '' },
    portrait: { src: '/images/hero/hero-portrait.webp', alt: 'Portrait de Malka Debleds' },
  }, // `images: { far, mid, near, mobile, portrait }` : emplacements d’image (`hero-far`, `hero-mid`, `hero-near`, `hero-mobile`, `hero-portrait`) définis en §9.3.3 et §6.2.3 ; fichiers fournis par Malka (§1.3.2) ; textes alternatifs en §7.17.
} as const;

Les chiffres de confiance ne sont plus dupliqués dans hero.ts : ils vivent une seule fois dans site.ts (schéma en §9.3.3), que le hero consomme sans les redéfinir.

// src/content/fr/site.ts — champs textuels (coordonnées identiques détaillées en §7.11/§7.12)
export const site = {
  brandName: 'Malka Debleds', // FIXE — nom de marque
  tagline: 'Accompagnement vibratoire et stratégique des entrepreneurs, pour des décisions alignées.', // PROPOSITION — à valider par Malka
  trustFigures: [
    { id: 'accompagnes', value: 300, suffix: '+', label: 'personnes accompagnées', ariaLabel: 'Plus de 300 personnes accompagnées' }, // FIXE (value, label) ; ariaLabel PROPOSITION
    { id: 'formes', value: 50, suffix: '+', label: 'personnes formées', ariaLabel: 'Plus de 50 personnes formées' }, // FIXE (value, label) ; ariaLabel PROPOSITION
  ],
  contact: { phoneDisplay: '06 67 29 51 79', phoneE164: '+33667295179', whatsappE164Digits: '33667295179', email: 'astheoris.pro@gmail.com', location: 'France — accompagnement à distance' }, // FIXE
} as const;

Notes d'implémentation (non ambiguës, pour que l'exécutant ne redéfinisse rien d'autre) :

  • L'affichage « 300+ » / « 50+ » du hero est composé par le composant à partir de value et suffix de site.trustFigures ; hero.ts ne recopie pas ce texte.
  • Le rail vertical du hero réutilise les ids et libellés Écouter, Explorer, Clarifier, Décider définis dans method.ts (§7.6) : un seul tableau de libellés, importé par les deux composants, pour éviter toute divergence de texte entre le rail du hero et la section Méthode. Le nom accessible de chaque lien du rail est son libellé visible (« Écouter », pas une phrase plus longue), regroupés sous railLabel (« Les quatre temps de la méthode ») porté par le conteneur du rail.
  • Le portrait de Malka n'a pas de texte dans hero.ts au-delà de sa référence d'image ; son texte alternatif est défini en §7.17.
  • La section hero n'a pas de chaîne d'accessibilité dédiée dans hero.ts : son nom accessible vient de aria-labelledby="accueil-titre", posé sur <section id="accueil"> et pointant vers l'unique <h1> (voir §4.2).

AC-7.4-01 : le titre est présent dans le HTML généré côté serveur sous forme de texte lisible (pas d'opacité 0 au premier rendu), conformément à la contrainte de performance définie ailleurs ; les 3 lignes du tableau headlineLines sont utilisées telles quelles par le composant de révélation, sans retraitement du texte. AC-7.4-02 : les deux ariaLabel des chiffres de confiance (site.trustFigures) sont annoncés par un lecteur d'écran à la place du texte visuel « 300+ » / « 50+ », qui reste ambigu hors contexte visuel.

7.5 Philosophie — « Entreprise vivante » #

Fichier : src/content/fr/philosophy.ts.

export const philosophy = {
  eyebrow: 'Philosophie', // PROPOSITION — à valider par Malka
  titleLines: ['Entreprise', 'vivante'], // FIXE — mot-marque « Entreprise vivante », une ligne par mot
  lead: 'Une entreprise n’est jamais séparée de la personne qui la porte.', // PROPOSITION — à valider par Malka
  body: [
    'Derrière chaque décision, chaque chiffre, chaque projet, il y a une personne : son énergie du moment, sa clarté ou sa fatigue, ses doutes et ses élans. Une entreprise vivante n’avance pas comme une machine détachée de son dirigeant ou de sa dirigeante ; elle respire au même rythme, se tend quand la personne se tend, se détend quand elle retrouve de l’espace.',
    'Considérer l’entreprise comme vivante, c’est accepter qu’elle traverse des saisons : des phases de croissance évidente, des phases de doute, des moments où tout semble aligné et d’autres où rien ne veut avancer. Ce n’est pas un défaut à corriger, c’est une réalité à regarder en face, avec la même attention qu’on porte à une personne plutôt qu’à un tableau de chiffres.',
    'C’est cette lecture-là qui est proposée ici : relier ce qui se vit chez vous à ce qui se joue dans votre activité, pour que les décisions prises aient du sens, de la cohérence et de la justesse — pas seulement de la performance.',
  ], // PROPOSITION — à valider par Malka, ~165 mots au total sur les trois paragraphes
  pullQuote: 'Ce que vous clarifiez en vous, vous le clarifiez dans votre entreprise.', // PROPOSITION — à valider par Malka
  principles: [
    {
      id: 'coherence',
      title: 'Cohérence',
      text: 'Vos décisions prennent sens quand elles reflètent qui vous êtes, pas seulement ce que l’on attend de vous.',
    },
    {
      id: 'clarte',
      title: 'Clarté',
      text: 'Voir clairement une situation ouvre la voie à une action juste, sans précipitation ni blocage.',
    },
    {
      id: 'mouvement',
      title: 'Mouvement',
      text: 'Une entreprise vivante évolue avec son dirigeant ou sa dirigeante, jamais à sa place.',
    },
  ], // PROPOSITION — à valider par Malka
} as const;

AC-7.5-01 : le corps du texte (body, trois paragraphes) compte entre 120 et 180 mots inclus au total ; une vérification automatisée (compteur de mots sur le texte concaténé des trois paragraphes) bloque toute régression lors d'une future édition. AC-7.5-02 : aucun des trois paragraphes n'emploie de vocabulaire thérapeutique ou de promesse de résultat (voir §7.1.1).

7.6 Méthode — Boussole de clarté #

Fichier : src/content/fr/method.ts.

export const method = {
  eyebrow: 'Méthode', // PROPOSITION — à valider par Malka
  title: 'Boussole de clarté', // FIXE — mot-marque
  intro: 'Quatre temps pour avancer avec discernement, du premier échange jusqu’à la décision qui vous engage.', // PROPOSITION — à valider par Malka
  tablistLabel: 'Les quatre temps de la méthode', // PROPOSITION
  instruction: 'Choisissez une étape : au clic, au toucher ou aux flèches du clavier.', // PROPOSITION
  previewHint: 'Aperçu — cliquez pour choisir cette étape', // PROPOSITION
  whatYouGetLabel: 'Ce que cette étape apporte', // PROPOSITION
  closing: { label: 'Découvrir les accompagnements', targetId: 'accompagnements' }, // PROPOSITION
  steps: [
    {
      id: 'ecouter',
      label: 'Écouter', // FIXE
      verb: 'Prendre le temps de nommer ce qui se joue.', // PROPOSITION
      summary: 'Tout commence par une écoute sans jugement de votre situation, de vos doutes et de ce qui vous amène aujourd’hui. Cette étape pose les bases d’une lecture juste.', // PROPOSITION
      details: [
        'Un espace pour exposer librement votre contexte',
        'Une attention portée autant aux faits qu’au ressenti',
        'Aucune conclusion hâtive à ce stade',
      ], // PROPOSITION
      whatYouGet: 'Votre situation est entendue, sans que vous ayez à tout justifier d’emblée.', // PROPOSITION
    },
    {
      id: 'explorer',
      label: 'Explorer', // FIXE
      verb: 'Ouvrir le champ des possibles.', // PROPOSITION
      summary: 'Les éléments posés sont examinés sous différents angles : votre parcours, votre entreprise, vos contraintes réelles. Cette exploration fait émerger des liens que l’urgence du quotidien masque souvent.', // PROPOSITION
      details: [
        'Un croisement entre votre histoire personnelle et votre activité',
        'Des questions qui déplacent le regard habituel',
        'Un repérage des tensions et des leviers disponibles',
      ], // PROPOSITION
      whatYouGet: 'Une vision plus large de votre situation, au-delà de l’urgence immédiate.', // PROPOSITION
    },
    {
      id: 'clarifier',
      label: 'Clarifier', // FIXE
      verb: 'Distinguer l’essentiel du secondaire.', // PROPOSITION
      summary: 'Les constats s’organisent pour faire émerger ce qui compte vraiment pour vous et pour votre entreprise. Cette étape transforme une situation confuse en priorités identifiables.', // PROPOSITION
      details: [
        'Une synthèse claire des constats partagés',
        'Une mise en évidence des priorités réelles',
        'Un langage simple, sans jargon superflu',
      ], // PROPOSITION
      whatYouGet: 'Une compréhension nette de ce qui doit être traité en premier.', // PROPOSITION
    },
    {
      id: 'decider',
      label: 'Décider', // FIXE
      verb: 'Choisir une direction et la première action.', // PROPOSITION
      summary: 'Clarifier ne suffit pas sans passer à l’action : cette dernière étape aide à choisir une direction et à poser une action concrète à engager, plutôt qu’une liste de tâches de plus.', // PROPOSITION
      details: [
        'Une direction choisie en conscience, pas par défaut',
        'Des actions concrètes et réalistes à engager',
        'Un point de repère pour les prochaines semaines',
      ], // PROPOSITION
      whatYouGet: 'Un cap clair pour agir, sans attendre une situation idéale.', // PROPOSITION
    },
  ],
} as const;

AC-7.6-01 : chaque summary compte entre 2 et 3 phrases, chaque tableau details compte entre 2 et 4 éléments (3 pour les quatre étapes livrées), chaque whatYouGet est une phrase unique — aucune case vide, aucun « … » de remplissage. AC-7.6-02 : les quatre label (« Écouter », « Explorer », « Clarifier », « Décider ») sont strictement identiques, caractère pour caractère, à ceux utilisés dans hero.ts (§7.4) et dans les ancres #methode-ecouter à #methode-decider définies en §4.2.1.

7.7 Accompagnements #

Fichier : src/content/fr/offers.ts.

export const offers = {
  eyebrow: 'Accompagnements', // PROPOSITION — à valider par Malka
  title: 'Choisir un accompagnement', // PROPOSITION — à valider par Malka
  intro: 'Plusieurs façons d’avancer, à choisir selon votre besoin du moment. Chaque accompagnement se déroule à distance.', // PROPOSITION — à valider par Malka
  featuredLabel: 'Pour faire le point', // PROPOSITION
  priceLabel: 'Tarif', // PROPOSITION
  detailOpenLabel: 'Voir le détail', // PROPOSITION
  detailCloseLabel: 'Masquer le détail', // PROPOSITION
  formLinkLabel: 'Écrire via le formulaire', // PROPOSITION
  ctaLabels: {
    whatsapp: 'Écrire sur WhatsApp', // PROPOSITION
    whatsappNewTabSuffix: '(ouvre WhatsApp dans un nouvel onglet)', // PROPOSITION — visuellement masqué, lu par les lecteurs d’écran
  },
  orientationHint: {
    text: 'Vous ne savez pas encore laquelle choisir ? La boussole d’orientation peut vous aider à y voir plus clair.', // PROPOSITION
    linkLabel: 'Essayer la boussole d’orientation', // PROPOSITION
  },
  offers: [
    {
      id: 'seance-clarification',
      title: 'Séance de clarification ciblée', // FIXE
      price: { kind: 'fixed', amountEur: 300 }, // FIXE — affiché via `formatPrice` (§9.3.4), jamais une chaîne saisie
      order: 1,
      featured: true,
      shortDescription: 'Un temps dédié pour clarifier une décision ou une situation précise.', // PROPOSITION — à valider par Malka
      details: [
        'Un sujet unique, traité en profondeur',
        'Un temps d’écoute dédié à votre situation',
        'Idéal face à une décision qui bloque',
      ], // PROPOSITION — à valider par Malka
      format: 'À distance', // FIXE
      whatsappMessage: 'Bonjour Malka, je souhaite en savoir plus sur « Séance de clarification ciblée ». Pouvons-nous échanger ?', // PROPOSITION — à valider par Malka
    },
    {
      id: 'lecture-globale',
      title: 'Lecture globale de l’entrepreneur et de l’entreprise', // FIXE
      price: { kind: 'fixed', amountEur: 150 }, // FIXE
      order: 2,
      featured: false,
      shortDescription: 'Une lecture d’ensemble pour relier votre posture personnelle et la situation de votre entreprise.', // PROPOSITION — à valider par Malka
      details: [
        'Une vision globale plutôt qu’un sujet isolé',
        'Des liens mis en lumière entre vous et votre activité',
        'Un point de départ clair pour la suite de l’accompagnement',
      ], // PROPOSITION — à valider par Malka
      format: 'À distance', // FIXE
      whatsappMessage: 'Bonjour Malka, je souhaite en savoir plus sur « Lecture globale de l’entrepreneur et de l’entreprise ». Pouvons-nous échanger ?', // PROPOSITION — à valider par Malka
    },
    {
      id: 'suivi-transition',
      title: 'Suivi de transition sur six mois', // FIXE
      price: { kind: 'fixed', amountEur: 1000 }, // FIXE
      order: 3,
      featured: false,
      shortDescription: 'Un accompagnement dans la durée pour traverser une période de transition.', // PROPOSITION — à valider par Malka
      details: [
        'Un fil conducteur sur toute la durée de la transition',
        'Un accompagnement dans la durée',
      ], // PROPOSITION — à valider par Malka
      format: 'À distance', // FIXE
      whatsappMessage: 'Bonjour Malka, je souhaite en savoir plus sur « Suivi de transition sur six mois ». Pouvons-nous échanger ?', // PROPOSITION — à valider par Malka
    },
    {
      id: 'accompagnement-personnalise',
      title: 'Accompagnement personnalisé', // FIXE
      price: { kind: 'quote', label: 'Sur devis' }, // FIXE
      order: 4,
      featured: false,
      shortDescription: 'Une proposition sur mesure, construite avec vous selon votre situation.', // PROPOSITION — à valider par Malka
      details: [
        'Un périmètre défini ensemble, sur devis',
        'Adapté aux besoins spécifiques de votre entreprise',
        'Un espace pour y voir plus clair, à votre rythme',
      ], // PROPOSITION — à valider par Malka
      format: 'À distance', // FIXE
      whatsappMessage: 'Bonjour Malka, je souhaite en savoir plus sur « Accompagnement personnalisé ». Pouvons-nous échanger ?', // PROPOSITION — à valider par Malka
    },
  ],
} as const;

La mention relative à la TVA affichée sous la grille des offres provient du slot légal fourni par Malka (legal.ts → vatMention, voir §7.13) ; ce n'est pas un champ de offers.ts, elle n'est jamais inventée ni laissée vide en production.

AC-7.7-01 : aucun shortDescription, details ou whatsappMessage ne mentionne une durée, un nombre ou une fréquence de séances non donnés par le titre de l’offre lui-même (par exemple, ne jamais ajouter « 5 séances » ou « 1 h 30 » qui ne sont pas des faits confirmés), ni une fréquence ou une continuité de suivi non confirmée ; aucune puce ne décrit un livrable (synthèse, compte rendu, plan d’action), un rythme (points d’étape) ou un support non fournis par Malka. AC-7.7-02 : price est toujours un objet { kind: 'fixed', amountEur } ou { kind: 'quote', label }, jamais une chaîne saisie à la main ; l'affichage (« 300 € », « 150 € », « 1 000 € » ou « Sur devis ») est produit par formatPrice (§9.3.4), qui utilise Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }) — séparateur de milliers et espace avant € déjà insécables nativement (voir §7.1.3). AC-7.7-03 : une seule offre a featured: true ; les quatre valeurs de order sont uniques (garde X-02, X-03, §9.3.5).

7.8 Boussole d'orientation #

Fichier : src/content/fr/orientation.ts.

export const orientation = {
  eyebrow: 'Orientation', // PROPOSITION — à valider par Malka
  title: 'Trouver votre point de départ', // PROPOSITION — à valider par Malka
  intro: 'Choisissez la situation qui vous ressemble le plus aujourd’hui pour une piste de réflexion adaptée.', // PROPOSITION — à valider par Malka
  groupLabel: 'Quelle situation vous ressemble le plus aujourd’hui ?', // PROPOSITION — légende du groupe de choix (fieldset/legend)
  globalDisclaimer: 'Cette boussole propose une piste de réflexion. Elle ne constitue ni un diagnostic ni une promesse de résultat.', // PROPOSITION — ne jamais retirer ni édulcorer ce garde-fou
  emptyState: 'Choisissez une situation ci-dessus pour afficher une piste de réflexion.', // PROPOSITION
  resultHeadings: {
    reflection: 'Une piste de réflexion', // PROPOSITION
    questions: 'Quelques questions pour vous', // PROPOSITION
    offer: 'Un accompagnement possible', // PROPOSITION
  },
  resetLabel: 'Choisir une autre situation', // PROPOSITION
  ctaLabels: {
    whatsapp: 'Écrire sur WhatsApp', // PROPOSITION
    whatsappNewTabSuffix: '(ouvre WhatsApp dans un nouvel onglet)', // PROPOSITION
    form: 'Écrire via le formulaire', // PROPOSITION
  },
  liveRegion: {
    situationSelected: 'Piste de réflexion affichée pour la situation {label}.', // PROPOSITION — {label} interpolé côté client
    selectionReset: 'Sélection réinitialisée. Choisissez une situation.', // PROPOSITION
  },
  situations: [
    {
      id: 'decision-bloquee',
      label: 'Décision bloquée', // FIXE
      prompt: 'Une décision tourne en boucle sans que vous parveniez à trancher. Plus vous y pensez, plus les options se brouillent, et l’urgence s’installe sans que la clarté vienne. Ce blocage dit souvent quelque chose d’important sur ce qui compte vraiment pour vous dans cette décision.', // PROPOSITION
      questions: [
        'Qu’est-ce qui vous empêche réellement de trancher ?',
        'Que craignez-vous si vous choisissiez une option plutôt que l’autre ?',
      ], // PROPOSITION
      suggestedOfferId: 'seance-clarification',
      suggestedOfferSentence: 'Une séance de clarification ciblée peut vous aider à démêler cette décision et à repartir avec une direction.', // PROPOSITION
      disclaimer: 'Cette piste de réflexion est un point de départ : la décision finale vous appartient entièrement.', // PROPOSITION
      whatsappMessage: 'Bonjour Malka, je me reconnais dans la situation « Décision bloquée » et j’aimerais en parler avec vous.', // PROPOSITION
    },
    {
      id: 'fatigue',
      label: 'Fatigue', // FIXE
      prompt: 'Vous avancez, mais l’énergie n’est plus la même. Ce qui portait votre élan au départ semble aujourd’hui plus lourd à soutenir. Cette fatigue mérite d’être regardée avant de décider quoi que ce soit dans l’urgence.', // PROPOSITION
      questions: [
        'Depuis quand ressentez-vous cette fatigue ?',
        'Qu’est-ce qui, dans votre activité, vous demande le plus d’énergie en ce moment ?',
        'Qu’est-ce qui vous ressourcerait si vous vous l’autorisiez ?',
      ], // PROPOSITION
      suggestedOfferId: 'lecture-globale',
      suggestedOfferSentence: 'Une lecture globale de votre situation peut aider à comprendre d’où vient cet épuisement et ce qu’il révèle.', // PROPOSITION
      disclaimer: 'Cette piste de réflexion ne remplace pas l’écoute de vos propres limites.', // PROPOSITION
      whatsappMessage: 'Bonjour Malka, je me reconnais dans la situation « Fatigue » et j’aimerais en parler avec vous.', // PROPOSITION
    },
    {
      id: 'repositionnement',
      label: 'Repositionnement', // FIXE
      prompt: 'Votre activité a évolué, ou vous sentez qu’elle devrait évoluer, sans savoir encore vers où. Ce moment de repositionnement demande de relire ce qui a changé, chez vous comme dans votre entreprise, avant de redéfinir un cap.', // PROPOSITION
      questions: [
        'Qu’est-ce qui, dans votre activité, ne vous correspond plus ?',
        'Vers quoi avez-vous envie d’orienter votre énergie aujourd’hui ?',
      ], // PROPOSITION
      suggestedOfferId: 'lecture-globale',
      suggestedOfferSentence: 'Une lecture globale de l’entrepreneur et de l’entreprise aide à poser les bases claires d’un repositionnement.', // PROPOSITION
      disclaimer: 'Cette piste de réflexion est un point de départ, pas une feuille de route toute faite.', // PROPOSITION
      whatsappMessage: 'Bonjour Malka, je me reconnais dans la situation « Repositionnement » et j’aimerais en parler avec vous.', // PROPOSITION
    },
    {
      id: 'transition',
      label: 'Transition', // FIXE
      prompt: 'Vous traversez un changement de taille — une étape de vie, un virage professionnel, une transformation de votre entreprise. Ces transitions prennent du temps et gagnent à être accompagnées dans la durée plutôt que traversées en solitaire.', // PROPOSITION
      questions: [
        'Qu’est-ce qui change concrètement pour vous actuellement ?',
        'De quoi auriez-vous besoin pour traverser cette période avec plus de stabilité ?',
        'Qu’est-ce qui, dans cette transition, dépend encore de vous ?',
      ], // PROPOSITION
      suggestedOfferId: 'suivi-transition',
      suggestedOfferSentence: 'Un suivi de transition sur six mois offre un accompagnement dans la durée pour traverser ce cap.', // PROPOSITION
      disclaimer: 'Cette piste de réflexion est un point de départ ; chaque transition suit son propre rythme.', // PROPOSITION
      whatsappMessage: 'Bonjour Malka, je me reconnais dans la situation « Transition » et j’aimerais en parler avec vous.', // PROPOSITION
    },
  ],
} as const;

AC-7.8-01 : le texte globalDisclaimer est affiché de façon visible (non masqué, non en infobulle) à chaque fois qu'une situation est sélectionnée, conformément au garde-fou éthique défini pour ce module (voir §6.6) ; le disclaimer propre à chaque situation s'affiche en complément, jamais à sa place. AC-7.8-02 : chaque prompt compte entre 2 et 4 phrases ; chaque tableau questions compte 2 ou 3 éléments ; aucun suggestedOfferSentence n'affirme un résultat garanti (verbes utilisés : « peut aider », « aide à », jamais « va résoudre » ou « garantit »). AC-7.8-03 : la correspondance situation → offre suggérée est strictement : Décision bloquée → seance-clarification, Fatigue → lecture-globale, Repositionnement → lecture-globale, Transition → suivi-transition.

7.9 À propos de Malka #

Fichier : src/content/fr/about.ts.

export const about = {
  eyebrow: 'Malka Debleds', // FIXE — nom de marque
  title: 'À propos', // PROPOSITION — à valider par Malka
  bio: {
    paragraphs: [], // FOURNI PAR MALKA — 1 à 4 paragraphes, 80 à 900 caractères chacun (total ≤ 2 200 caractères), texte libre de Malka (parcours, légitimité, ce qui l’anime professionnellement). Jamais inventé ici.
    devPlaceholder: 'Biographie de Malka — texte en cours de rédaction, affiché uniquement hors production.', // PROPOSITION — affiché uniquement hors production tant que `paragraphs` est vide (voir garde-fou ci-dessous)
  },
  posture: {
    title: 'Posture professionnelle', // PROPOSITION — à valider par Malka
    body: 'Dans chaque échange, la posture est la même : une écoute attentive, sans jugement, et un regard qui relie ce qui se vit chez vous à ce qui se joue dans votre entreprise.', // PROPOSITION — à valider par Malka ; ne contient aucun fait biographique
  },
  approach: {
    title: 'Approche humaine', // PROPOSITION — à valider par Malka
    body: 'L’accompagnement avance à votre rythme, avec des mots simples et un espace où vous pouvez déposer ce qui est réellement là, loin des injonctions à toujours performer.', // PROPOSITION — à valider par Malka ; ne contient aucun fait biographique
  },
  portrait: { src: '/images/about/about-portrait.webp', alt: 'Malka Debleds, portrait de la section À propos', width: 1600, height: 2000 }, // emplacement d’image requis par `aboutSchema` ; fichier fourni par Malka (§1.3.2)
} as const;

Garde-fou de build pour bio.paragraphs : en APP_ENV=production, la garde G-05 échoue si la clé biography du registre content-validation.json n'est pas validée (§1.3.3) ou si bio.paragraphs compte moins de deux éléments ; le message d'erreur nomme la clé et le fichier. En développement ou en préproduction, l'interface affiche bio.devPlaceholder à la place du bloc biographique, dans un encart visuellement distinct (même traitement que le bandeau légal, voir §7.13) qui n'apparaît jamais en production.

AC-7.9-01 : aucune phrase de posture.body ou approach.body n'affirme un fait vérifiable sur le passé, la formation, les certifications ou l'historique professionnel de Malka — seules des formulations de posture, non factuelles, sont utilisées. AC-7.9-02 : le portrait « à propos » référencé par cette section utilise le texte alternatif défini en §7.17, jamais un texte ad hoc. AC-7.9-03 : about.ts ne définit pas de rubrique « valeurs » distincte de celles de la Philosophie (§7.5) — une seule liste de principes existe sur le site, celle de §7.5.

7.10 Témoignages #

Fichier : src/content/fr/testimonials.ts (le contenu éditorial ci-dessous complète, sans le redéfinir, le schéma de données déjà fixé : voir §9.3 pour les champs authorName, authorContext, quote, consent, published).

export const testimonials = {
  eyebrow: 'Témoignages', // PROPOSITION — à valider par Malka
  title: 'Témoignages', // PROPOSITION — à valider par Malka
  intro: 'Les retours partagés par celles et ceux qui ont vécu l’accompagnement.', // PROPOSITION — à valider par Malka
  verificationNote: 'Témoignages publiés avec l’accord écrit de leurs auteurs, reproduits mot pour mot.', // PROPOSITION — à valider par Malka ; non vide dès qu’un témoignage est publiable (garde X-10)
  regionAriaLabel: 'Témoignages clients', // PROPOSITION
  roleDescriptions: { carousel: 'carrousel', slide: 'témoignage' }, // PROPOSITION
  slideLabel: 'Témoignage {n} sur {total}', // PROPOSITION — {n}/{total} interpolés
  offerTagLabel: 'Accompagnement concerné', // PROPOSITION
  controls: {
    previousLabel: 'Témoignage précédent', // PROPOSITION
    nextLabel: 'Témoignage suivant', // PROPOSITION
    paginationAriaLabel: 'Afficher le témoignage {n} sur {total}', // PROPOSITION — {n}/{total} interpolés
  },
  liveRegion: {
    slideChanged: 'Témoignage {n} sur {total} : {authorName}.', // PROPOSITION
  },
  authorDisplay: {
    withContext: '{authorName} — {authorContext}', // PROPOSITION, ex. rendu : « Stéphanie — Steff Beauté Plurielle »
    withoutContext: '{authorName}', // PROPOSITION, ex. rendu : « Émilie »
  },
  items: [
    { id: 'stephanie', authorName: 'Stéphanie', authorContext: 'Steff Beauté Plurielle', quote: '', consent: { status: 'pending' }, published: false }, // FOURNI PAR MALKA pour `quote`
    { id: 'emilie', authorName: 'Émilie', quote: '', consent: { status: 'pending' }, published: false }, // FOURNI PAR MALKA pour `quote`
  ],
} as const;

Le fichier contient exactement ces deux entrées — aucun texte de citation n'est écrit dans cette section ni ailleurs dans la spécification : il est FOURNI PAR MALKA, copié verbatim depuis son site actuel après vérification de l'authenticité et de l'autorisation de publication. Lorsque ces conditions sont remplies (voir la règle de publication en §9.3), les champs published, consent.status, consent.grantedOn et quote sont mis à jour dans le fichier de données — aucun autre texte de cette section ne change.

AC-7.10-01 : cette section et le fichier qu'elle documente ne contiennent, à aucun moment, un texte de citation inventé, même à titre d'exemple ou de texte de remplacement — en l'absence de témoignage publiable, la section et le lien de navigation correspondant sont absents du DOM (voir §6.8), sans message de type « bientôt disponible ». AC-7.10-02 : aucune photo de client, ni portrait généré, ni monogramme n'accompagne un témoignage — uniquement le texte (authorDisplay ci-dessus) et la citation.

7.11 Contact et prise de rendez-vous #

Fichier : src/content/fr/contact.ts.

7.11.1 Textes généraux et carte WhatsApp #

export const contact = {
  eyebrow: 'Contact', // PROPOSITION
  title: 'Prendre contact', // PROPOSITION — à valider par Malka
  intro: 'Un message, un appel ou un rendez-vous : à vous de choisir ce qui vous convient. Chaque échange commence par une écoute, sans engagement de votre part.', // PROPOSITION — à valider par Malka
  whatsappCard: {
    title: 'Discuter sur WhatsApp', // PROPOSITION
    text: 'Écrivez directement à Malka sur WhatsApp.', // PROPOSITION
    ctaLabel: 'Écrire sur WhatsApp', // PROPOSITION
    ctaNewTabSuffix: '(ouvre WhatsApp dans un nouvel onglet)', // PROPOSITION
    defaultMessage: 'Bonjour Malka, je vous contacte depuis le site Entreprise vivante.', // PROPOSITION
  },
  form: {
    title: 'Écrire via le formulaire', // PROPOSITION
    intro: 'Les champs non marqués « facultatif » sont obligatoires.', // PROPOSITION
    dataNote: 'Vos informations servent uniquement à répondre à votre demande (voir la politique de confidentialité).', // PROPOSITION
  },
  fields: {
    optionalSuffix: '(facultatif)', // PROPOSITION
    characterCount: '{n} / {max} caractères', // PROPOSITION — {n}/{max} interpolés
    name: { label: 'Nom complet' }, // PROPOSITION
    email: { label: 'E-mail' }, // PROPOSITION
    phone: { label: 'Téléphone', hint: 'Facultatif — si vous préférez qu’on vous rappelle.' }, // PROPOSITION
    message: { label: 'Votre message', hint: '20 caractères minimum.' }, // PROPOSITION
    offerId: { label: 'Vous souhaitez échanger à propos de', noneOptionLabel: 'Prise de contact générale' }, // PROPOSITION
    situationId: { label: 'Votre situation', hint: 'Présélectionnée automatiquement si vous arrivez depuis la boussole d’orientation ; modifiable à tout moment.', noneOptionLabel: 'Aucune situation en particulier' }, // PROPOSITION
    consent: {
      label: 'J’ai lu la {privacyLink} et j’accepte que Malka Debleds utilise ces informations pour me répondre.', // PROPOSITION — {privacyLink} remplacé par un lien vers `/politique-de-confidentialite`
      privacyLinkLabel: 'politique de confidentialité', // PROPOSITION
      privacyLinkNewTabSuffix: '(s’ouvre dans un nouvel onglet)', // PROPOSITION
    },
    website: { label: 'Ne pas remplir ce champ' }, // PROPOSITION
  },
  prefillAnnouncement: {
    byOffer: 'Formulaire prérempli avec l’offre : {offerTitle}.', // PROPOSITION — {offerTitle} interpolé
    bySituation: 'Formulaire prérempli avec la situation : {situationLabel}.', // PROPOSITION — {situationLabel} interpolé
  },
  validation: {
    name: {
      required: 'Veuillez indiquer votre nom.',
      tooShort: 'Votre nom doit contenir au moins 2 caractères.',
      tooLong: 'Votre nom ne peut pas dépasser 80 caractères.',
      invalid: 'Votre nom contient des caractères non autorisés.',
    },
    email: {
      required: 'Veuillez indiquer votre adresse e-mail.',
      invalid: 'Veuillez indiquer une adresse e-mail valide.',
      tooLong: 'Cette adresse e-mail est trop longue.',
    },
    phone: {
      invalid: 'Veuillez indiquer un numéro de téléphone valide (chiffres, espaces, « + », « ( », « ) » et « - » uniquement).',
      tooLong: 'Ce numéro de téléphone est trop long.',
    },
    offerId: {
      invalid: 'Veuillez choisir un accompagnement dans la liste proposée.',
    },
    situationId: {
      invalid: 'Veuillez choisir une situation dans la liste proposée.',
    },
    message: {
      required: 'Veuillez indiquer votre message.',
      tooShort: 'Votre message doit contenir au moins 20 caractères.',
      tooLong: 'Votre message ne peut pas dépasser 2 000 caractères.',
      tooManyLinks: 'Votre message ne peut pas contenir plus de deux liens.',
    },
    consent: {
      required: 'Veuillez accepter que vos informations soient utilisées pour vous répondre.',
    },
    generic: 'Certaines informations sont manquantes ou invalides. Merci de vérifier les champs du formulaire.',
  },
  submit: {
    idleLabel: 'Envoyer ma demande', // PROPOSITION
    loadingLabel: 'Envoi en cours…', // PROPOSITION
    loadingAriaLive: 'Envoi de votre message en cours.', // PROPOSITION — région aria-live="polite"
  },
  errorSummary: {
    titleOne: 'Une information est à corriger', // PROPOSITION
    titleMany: '{n} informations sont à corriger', // PROPOSITION — {n} interpolé
  },
  failureBanner: {
    title: 'Votre message n’a pas pu être envoyé', // PROPOSITION
    fallbackIntro: 'Vous pouvez aussi écrire directement à Malka :', // PROPOSITION
    retryLabel: 'Réessayer', // PROPOSITION
    emailLinkLabel: 'Écrire par e-mail', // PROPOSITION
    emailSubject: 'Prise de contact depuis le site', // PROPOSITION
  },
  retryAfter: {
    underMinute: 'Merci de patienter moins d’une minute.', // PROPOSITION
    minuteOne: 'Merci de patienter encore 1 minute.', // PROPOSITION
    minuteMany: 'Merci de patienter encore {n} minutes.', // PROPOSITION — {n} interpolé
    hourOne: 'Merci de patienter encore 1 heure.', // PROPOSITION
    hourMany: 'Merci de patienter encore {n} heures.', // PROPOSITION — {n} interpolé
    unknown: 'Merci de réessayer un peu plus tard.', // PROPOSITION
  },
  apiErrors: {
    VALIDATION_ERROR: 'Certaines informations sont manquantes ou invalides. Merci de vérifier les champs du formulaire.',
    INVALID_JSON: 'Une erreur technique empêche l’envoi de votre message. Merci de réessayer, ou d’écrire directement à Malka sur WhatsApp.',
    UNSUPPORTED_MEDIA_TYPE: 'Une erreur technique empêche l’envoi de votre message. Merci de réessayer dans quelques instants, ou d’écrire à Malka sur WhatsApp.',
    PAYLOAD_TOO_LARGE: 'Votre message est trop volumineux. Merci de le raccourcir et de réessayer.',
    FORBIDDEN_ORIGIN: 'Une erreur technique empêche l’envoi de votre message pour le moment. Merci de réessayer, ou d’écrire directement à Malka sur WhatsApp.',
    RATE_LIMITED: 'Vous avez envoyé plusieurs messages récemment. Merci de patienter quelques minutes avant de réessayer, ou contactez Malka directement sur WhatsApp.',
    EMAIL_DELIVERY_FAILED: 'Votre message n’a pas pu être envoyé pour le moment. Merci de réessayer dans quelques instants, ou contactez Malka directement par WhatsApp ou par e-mail.',
    INTERNAL_ERROR: 'Une erreur inattendue est survenue. Merci de réessayer dans quelques instants, ou contactez Malka directement par WhatsApp.',
    METHOD_NOT_ALLOWED: 'Cette action n’est pas disponible.',
    OFFLINE: 'Votre connexion internet semble interrompue. Vérifiez votre connexion puis réessayez, ou contactez Malka directement par WhatsApp.',
    NETWORK_ERROR: 'Le message n’a pas pu être envoyé : la connexion au site a échoué. Merci de réessayer, ou de contacter Malka directement par WhatsApp.',
    TIMEOUT: 'L’envoi prend plus de temps que prévu. Merci de réessayer, ou de contacter Malka directement par WhatsApp.',
  },
  success: {
    title: 'Message envoyé', // PROPOSITION
    body: 'Merci, votre message a bien été transmis à Malka. Vous pouvez aussi lui écrire directement sur WhatsApp.', // PROPOSITION
    resetLabel: 'Envoyer un autre message', // PROPOSITION
  },
  noscript: {
    text: 'Le formulaire nécessite JavaScript pour fonctionner. Vous pouvez écrire directement à Malka sur WhatsApp ou par e-mail : astheoris.pro@gmail.com.', // PROPOSITION
  },
} as const;

Les coordonnées (téléphone, e-mail) ne sont pas répétées dans ce littéral : la carte WhatsApp et le repli sans JavaScript réutilisent site.contact.phoneDisplay/site.contact.email (voir §7.4).

Il n'existe aucun gabarit de phrase de pré-remplissage (pas de contactPrefill, pas de prefill) : requestContactPrefill({ offerId?, situationId? }) se contente de présélectionner l'option correspondante dans les menus offerId/situationId ci-dessus — chaque option porte déjà son propre libellé visible, aucune phrase supplémentaire n'est nécessaire, et le champ message n'est jamais touché.

7.11.2 Champs du formulaire #

Champ Obligatoire Détail
name Oui Nom complet.
email Oui Adresse e-mail.
phone Non Téléphone, format libre (chiffres, espaces, +, (, ), -).
offerId Non Menu déroulant ; options dans l'ordre : « Prise de contact générale » (valeur non-defini, par défaut), puis les titres des offres actives de offers.ts (§7.7), dans leur ordre d'affichage.
situationId Non Menu déroulant visible ; options : « Aucune situation en particulier » (par défaut), puis les quatre libellés de situation de orientation.ts (§7.8). Présélectionné automatiquement, jamais via l'URL, lorsque le visiteur arrive depuis la boussole d'orientation ou depuis une offre.
message Oui Jamais pré-rempli : le champ reste vide quelle que soit l'origine de la visite ; seuls offerId/situationId sont présélectionnés.
consent Oui (case à cocher) Atteste la lecture de la politique de confidentialité (§7.13.2) ; ce n'est pas elle qui constitue la base légale du traitement (voir §7.13.2).
website (honeypot) — Champ invisible, aria-hidden="true", non focusable (tabIndex=-1), jamais présenté aux technologies d'assistance, nom technique website pour attirer les robots.

Les libellés, textes d'aide et textes de remplacement exacts sont ceux de fields ci-dessus (§7.11.1). Lorsque le visiteur arrive depuis une carte de la boussole d'orientation (§7.8) ou depuis une offre (§7.7), requestContactPrefill({ offerId?, situationId? }) présélectionne l'option correspondante dans offerId/situationId et fait défiler la page jusqu'à la section Contact — jamais via un paramètre d'URL pour situationId. Le champ message n'est jamais pré-rempli : le visiteur l'écrit entièrement lui-même.

7.11.3 Messages de validation par champ #

Les messages de validation par champ sont les clés validation.<champ>.<règle> et validation.generic du littéral contact défini en §7.11.1.

Le champ website (honeypot) et le champ technique startedAt n'affichent jamais de message à l'utilisateur : une soumission détectée comme suspecte sur ces critères reçoit une réponse de succès silencieuse côté interface (voir le contrat d'erreur en §9.4) ; aucun texte n'est donc nécessaire pour ce cas.

7.11.4 États du bouton d'envoi et de la zone de statut #

Les trois états du bouton d'envoi sont les clés submit.idleLabel|loadingLabel|loadingAriaLive du littéral contact (§7.11.1) ; loadingAriaLive est porté par une région aria-live="polite". La désactivation du bouton pendant l'envoi est un comportement de composant (aria-disabled, voir §6.9), pas une chaîne de contenu.

7.11.5 Messages d'erreur par code d'API #

Les douze clés de apiErrors (neuf codes d'API + OFFLINE, NETWORK_ERROR, TIMEOUT), ainsi que errorSummary.titleOne|titleMany, failureBanner.* et retryAfter.*, sont définies dans le littéral contact (§7.11.1).

Règles d'affichage :

  • VALIDATION_ERROR affiche en priorité les messages de champ (validation.*, §7.11.3) issus de error.fields ; le message générique (validation.generic) n'apparaît qu'en bandeau au-dessus du formulaire, en complément, jamais seul si des messages de champ existent.
  • RATE_LIMITED complète son message par une durée d'attente lisible calculée à partir de l'en-tête Retry-After (secondes arrondies à la minute ou à l'heure supérieure, n = ⌈s/60⌉) : le texte ajouté provient de retryAfter.underMinute|minuteOne|minuteMany|hourOne|hourMany|unknown (§7.11.1), selon la durée restante.
  • EMAIL_DELIVERY_FAILED, NETWORK_ERROR et TIMEOUT affichent, en plus du message, les deux liens de repli (WhatsApp et mailto:astheoris.pro@gmail.com) déjà présents dans whatsappCard (§7.11.1) ; OFFLINE s'affiche sans lien de repli.
  • METHOD_NOT_ALLOWED ne devrait jamais être visible dans un parcours normal (protection de robustesse uniquement) ; s'il apparaît, le message générique (validation.generic) suffit.

7.11.6 Panneau de succès et repli sans JavaScript #

Le panneau de succès (clés success.title|body|resetLabel) et le repli sans JavaScript (clé noscript.text) sont définis dans le littéral contact (§7.11.1). Le bouton WhatsApp du panneau de succès réutilise whatsappCard.ctaLabel et le lien produit par getBookingAction({}) ; aucune clé whatsappCtaLabel ou backHomeLabel dédiée n'existe pour ce panneau.

AC-7.11-01 : chaque règle de validation listée en §9.4 (longueurs minimale/maximale, format) dispose d'un message en §7.11.3 ; aucune règle de validation du contrat d'API n'est sans message correspondant. AC-7.11-02 : les douze clés de apiErrors — neuf codes d'API (VALIDATION_ERROR, INVALID_JSON, UNSUPPORTED_MEDIA_TYPE, PAYLOAD_TOO_LARGE, FORBIDDEN_ORIGIN, RATE_LIMITED, EMAIL_DELIVERY_FAILED, INTERNAL_ERROR, METHOD_NOT_ALLOWED) ainsi que OFFLINE, NETWORK_ERROR, TIMEOUT — disposent chacune d'un message en §7.11.5, sans exception. AC-7.11-03 : le panneau de succès et le message de confirmation n'échouent jamais à cause d'un contenu utilisateur manquant — ils sont entièrement statiques, aucune interpolation de champ saisi.

Fichier : src/content/fr/footer.ts.

export const footer = {
  wordmark: 'CLARTÉ', // FIXE — mot-marque décoratif, masqué aux technologies d’assistance (voir §8)
  ctaCard: {
    title: 'Envie d’y voir plus clair ?', // PROPOSITION — à valider par Malka
    text: 'Écrivez sur WhatsApp ou utilisez le formulaire, sans engagement.', // PROPOSITION — à valider par Malka
    whatsappLabel: 'Écrire sur WhatsApp', // PROPOSITION
    whatsappNewTabSuffix: '(ouvre WhatsApp dans un nouvel onglet)', // PROPOSITION
    formLabel: 'Écrire via le formulaire', // PROPOSITION
  },
  columns: {
    navigation: {
      title: 'Navigation', // PROPOSITION
      ariaLabel: 'Navigation du pied de page', // PROPOSITION
      // liens repris de navigation.ts (§7.3), sans retexte
    },
    contact: {
      title: 'Contact', // PROPOSITION
      // coordonnées reprises de site.ts (§7.4), sans retexte : téléphone, e-mail, « France — accompagnement à distance »
    },
    legal: {
      title: 'Informations légales', // PROPOSITION
      ariaLabel: 'Informations légales', // PROPOSITION
      links: [
        { label: 'Mentions légales', href: '/mentions-legales' },
        { label: 'Politique de confidentialité', href: '/politique-de-confidentialite' },
      ], // PROPOSITION
    },
  },
  backToTopLabel: 'Retour en haut', // PROPOSITION
  copyright: '© {year} Malka Debleds. Tous droits réservés.', // PROPOSITION — {year} interpolé à l’année courante au moment du rendu
} as const;

AC-7.12-01 : copyright interpole l'année courante côté serveur au moment du rendu (pas de valeur figée écrite en dur dans le fichier de contenu). AC-7.12-02 : les coordonnées affichées dans la colonne « Contact » sont identiques, caractère pour caractère, à celles de site.ts et du hero (§7.4) — une seule source de vérité réutilisée, jamais retapée différemment, jamais redupliquée dans footer.ts.

7.13 Pages légales #

Fichier : src/content/fr/legal.ts pour les slots fournis par Malka. Les titres et paragraphes explicatifs ci-dessous vivent dans src/lib/legal/page-copy.ts (legalPageCopy, privacyPageCopy, getPrivacySections), module .ts sans JSX, couvert par frTypo et par la clé copyDeck du registre content-validation.json — jamais une chaîne écrite en dur dans src/app/mentions-legales/page.tsx ou src/app/politique-de-confidentialite/page.tsx.

7.13.1 Mentions légales #

Titre de page : « Mentions légales ».

export const legalPageCopy = {
  pageTitle: 'Mentions légales', // PROPOSITION
  intro: 'Les informations ci-dessous identifient l’éditeur et l’hébergeur de ce site, conformément à la réglementation en vigueur.', // PROPOSITION
  sections: {
    publisher: {
      heading: 'Éditeur du site', // PROPOSITION
      // Gabarit de rendu, valeurs issues de legal.ts → publisher (FOURNI PAR MALKA) :
      template: '{legalName}{tradeNameSuffix}, {legalForm}.\nSIRET : {siret}.\nAdresse : {address}.\nContact : {email} — {phone}.\nDirecteur ou directrice de la publication : {publicationDirector}.{shareCapitalLine}{registrationLine}',
      // `{tradeNameSuffix}` = ` (nom commercial : {tradeName})` si `tradeName` est non vide, sinon chaîne vide.
      // `{shareCapitalLine}` = `\nCapital social : {shareCapital}.` si `shareCapital` est renseigné, sinon vide ; `{registrationLine}` = `\nImmatriculation : {registration}.` si `registration` est renseigné, sinon vide.
    },
    host: {
      heading: 'Hébergement', // PROPOSITION
      // Gabarit de rendu, valeurs issues de legal.ts → host (FOURNI PAR MALKA) :
      template: '{name}, {address}. Contact : {phone}.',
    },
    vat: {
      heading: 'Mention relative à la TVA', // PROPOSITION
      // Valeur issue de legal.ts → vatMention (FOURNI PAR MALKA), affichée telle quelle, sans reformulation.
    },
    proprieteIntellectuelle: {
      heading: 'Propriété intellectuelle', // PROPOSITION — à valider par Malka
      body: 'L’ensemble des contenus de ce site (textes, visuels, mise en forme) est protégé par le droit de la propriété intellectuelle. Toute reproduction, représentation ou réutilisation, totale ou partielle, sans autorisation préalable, est interdite.', // PROPOSITION — à valider par Malka
    },
    donneesEtCookies: {
      heading: 'Données personnelles et cookies', // PROPOSITION — à valider par Malka
      body: 'Les données personnelles collectées sur ce site sont traitées conformément à la politique de confidentialité, accessible à l’adresse /politique-de-confidentialite. Ce site ne dépose aucun cookie de mesure d’audience ou de publicité.', // PROPOSITION — à valider par Malka
    },
    mediation: {
      heading: 'Médiation de la consommation', // PROPOSITION — à valider par Malka
      // Rendu uniquement si legal.ts → mediator est renseigné (statut professionnel en relation avec des particuliers à confirmer, voir §1.3) :
      template: 'Conformément à la réglementation applicable à la médiation de la consommation, un médiateur peut être saisi en cas de litige non résolu directement avec l’éditeur du site : {name} ({url}).',
      renderOnlyWhen: 'legal.mediator est défini', // condition de rendu, pour l’exécutant
    },
    liensExternes: {
      heading: 'Liens externes', // PROPOSITION — à valider par Malka
      body: 'Ce site peut contenir des liens vers des sites tiers, notamment WhatsApp. L’éditeur du site n’est pas responsable du contenu ni des pratiques de confidentialité de ces sites tiers.', // PROPOSITION — à valider par Malka
    },
    lastUpdated: {
      label: 'Dernière mise à jour :', // PROPOSITION
      // Valeur issue de legal.ts → lastUpdated (FOURNI PAR MALKA, format YYYY-MM-DD affiché en français : « 1er janvier 2026 »).
    },
  },
  devPlaceholderBanner: 'Informations légales à fournir par Malka avant mise en production.', // PROPOSITION — bandeau visible uniquement hors production tant qu’un champ requis de legal.ts est vide
} as const;

7.13.2 Politique de confidentialité #

Titre de page : « Politique de confidentialité ».

export const privacyPageCopy = {
  pageTitle: 'Politique de confidentialité', // PROPOSITION
  intro: 'Cette page explique quelles informations sont collectées sur ce site, pour quelle raison, et comment vous pouvez exercer vos droits.', // PROPOSITION
  sections: [
    {
      id: 'responsable',
      heading: 'Responsable du traitement',
      // Gabarit de rendu, valeurs issues de legal.ts → publisher (FOURNI PAR MALKA) :
      template: 'Le responsable du traitement des données collectées sur ce site est {legalName} ({email}).',
    },
    {
      id: 'formulaire',
      heading: 'Formulaire de contact',
      // {retentionPeriod} issu de legal.ts → privacy.retention (FOURNI PAR MALKA, requis ; recommandation de la CNIL indiquée en §1.3, jamais une valeur par défaut livrée ici) :
      template: 'Lorsque vous utilisez le formulaire de contact, les informations suivantes sont collectées : votre nom, votre adresse e-mail, votre numéro de téléphone si vous le renseignez, le contenu de votre message, ainsi que l’accompagnement ou la situation éventuellement sélectionnés. Ces informations sont utilisées uniquement pour répondre à votre demande de contact ou de rendez-vous ; elles ne sont ni revendues ni utilisées à des fins commerciales autres que cette réponse. Le traitement repose sur les mesures précontractuelles prises à votre demande (article 6, paragraphe 1, point b, du Règlement général sur la protection des données). Votre message est transmis par l’intermédiaire du prestataire d’envoi d’e-mails transactionnels Brevo (société française, données hébergées dans l’Union européenne), qui agit en tant que sous-traitant. Ce site ne dispose d’aucune base de données : votre message n’est pas stocké sur le serveur du site ; il est transmis par e-mail à l’éditeur du site, qui le conserve {retentionPeriod}.',
    },
    {
      id: 'accuse-reception',
      heading: 'Accusé de réception et sécurité',
      body: 'Un e-mail automatique de confirmation vous est envoyé après l’envoi du formulaire. Des informations techniques minimales (horodatage de la soumission, indicateurs anti-robots) sont conservées temporairement pour la sécurité du site. Ce traitement repose sur l’intérêt légitime de l’éditeur du site à confirmer la bonne réception de votre demande et à prévenir les abus (article 6, paragraphe 1, point f, du Règlement général sur la protection des données).',
    },
    {
      id: 'whatsapp',
      heading: 'Liens vers WhatsApp',
      body: 'Les boutons « Écrire sur WhatsApp » de ce site ouvrent l’application ou le site WhatsApp (service édité par Meta). Lorsque vous utilisez ce bouton, les informations que vous échangez ensuite sur WhatsApp sont soumises à la politique de confidentialité propre à WhatsApp, indépendante de ce site.',
    },
    {
      id: 'boussole',
      heading: 'Boussole d’orientation',
      body: 'Les réponses données dans la boussole d’orientation restent dans votre navigateur le temps de la visite : elles ne sont ni enregistrées, ni transmises à l’éditeur du site, sauf si vous envoyez ensuite le formulaire de contact en conservant la situation présélectionnée.',
    },
    {
      id: 'cookies',
      heading: 'Cookies et traceurs',
      body: 'Ce site ne dépose aucun cookie de mesure d’audience ou de publicité. Aucun consentement aux cookies n’est donc demandé.',
    },
    {
      id: 'audience',
      heading: 'Mesure d’audience (si activée)',
      body: 'Lorsque la mesure d’audience respectueuse de la vie privée est activée par l’éditeur du site, elle fonctionne sans cookie et sans collecte de donnée personnelle identifiante : aucune adresse IP complète ni identifiant individuel n’est conservé. Cette mesure sert uniquement à des statistiques de fréquentation globales et anonymes. Si vous activez l’option de désactivation proposée sur le site, une préférence technique est enregistrée dans votre navigateur (stockage local) pour en garder le souvenir ; aucune autre donnée n’est stockée localement sur ce site. Cette section n’apparaît sur cette page que lorsque cette mesure est effectivement activée.',
      renderOnlyWhen: 'ANALYTICS_PROVIDER=umami', // condition de rendu, pour l’exécutant
    },
    {
      id: 'droits',
      heading: 'Vos droits',
      body: 'Conformément au Règlement général sur la protection des données et à la loi Informatique et Libertés, vous disposez d’un droit d’accès, de rectification, d’effacement et d’opposition concernant vos données personnelles. Pour exercer ces droits, vous pouvez écrire à astheoris.pro@gmail.com.',
    },
    {
      id: 'reclamation',
      heading: 'Réclamation',
      body: 'Si vous estimez, après avoir contacté l’éditeur du site, que vos droits ne sont pas respectés, vous pouvez adresser une réclamation à la Commission nationale de l’informatique et des libertés (CNIL), à l’adresse https://www.cnil.fr/fr/plaintes.',
    },
    {
      id: 'mise-a-jour',
      heading: 'Mise à jour de cette politique',
      body: 'Cette politique de confidentialité peut être mise à jour pour refléter des changements dans le fonctionnement du site. La date de dernière mise à jour est indiquée en bas de cette page.',
    },
  ], // PROPOSITION — texte final, à valider par Malka avant publication
  lastUpdatedLabel: 'Dernière mise à jour :', // PROPOSITION — valeur issue de legal.ts → lastUpdated (FOURNI PAR MALKA)
} as const;

AC-7.13-01 : aucune donnée d'identité légale (SIRET, forme juridique, adresse, nom de l'hébergeur, directeur de publication, mention TVA, nom et URL du médiateur) n'apparaît en dur dans le texte ci-dessus : chaque occurrence est un gabarit (template) consommant un champ de legal.ts, jamais une valeur écrite directement dans la copy ; la durée de conservation ({retentionPeriod}) provient de legal.ts → privacy.retention, jamais d'une valeur par défaut livrée dans ce fichier. AC-7.13-02 : la section « Mesure d’audience » de la politique de confidentialité n'est rendue que lorsque ANALYTICS_PROVIDER=umami ; elle est absente du DOM (pas seulement masquée visuellement) lorsque la mesure d'audience est désactivée. La section « Médiation de la consommation » des mentions légales n'est rendue que lorsque legal.ts → mediator est renseigné. AC-7.13-03 : le bandeau devPlaceholderBanner n'est jamais rendu lorsque APP_ENV=production (le build échoue avant, voir §7.9 et §9.3) — il ne peut donc techniquement jamais apparaître en production. AC-7.13-04 : la base légale affichée dans la section « Formulaire de contact » (mesures précontractuelles, article 6.1.b) et celle de la section « Accusé de réception et sécurité » (intérêt légitime, article 6.1.f) sont les deux seules bases légales citées sur le site ; la case consent (§7.11.2) n'est jamais présentée comme une base légale, seulement comme l'attestation de lecture de cette page.

7.14 Pages système — 404 et erreur #

Ces textes vivent dans src/lib/system-pages/copy.ts (notFoundCopy, errorPageCopy, module client-safe car utilisés par error.tsx) — pas de fichier de contenu src/content/fr/*.ts dédié : volume de texte trop faible pour justifier un fichier séparé.

export const notFoundCopy = {
  pageTitleTag: 'Page introuvable — Malka Debleds', // PROPOSITION — balise <title>
  heading: 'Page introuvable', // PROPOSITION
  body: 'Cette page n’existe pas ou a été déplacée.', // PROPOSITION
  ctaLabel: 'Retour à l’accueil', // PROPOSITION
  ctaHref: '/',
  whatsappCtaLabel: 'Écrire sur WhatsApp', // PROPOSITION — lien WhatsApp produit par `getBookingAction` (§9.9)
} as const;

export const errorPageCopy = {
  heading: 'Une erreur est survenue', // PROPOSITION
  body: 'Quelque chose n’a pas fonctionné comme prévu. Vous pouvez réessayer ou retourner à l’accueil.', // PROPOSITION
  retryLabel: 'Réessayer', // PROPOSITION
  backHomeLabel: 'Retour à l’accueil', // PROPOSITION
  whatsappCtaLabel: 'Écrire sur WhatsApp', // PROPOSITION — lien WhatsApp produit par `getBookingAction` (§9.9)
  digestLabel: 'Référence : {digest}', // PROPOSITION — {digest} interpolé par Next.js lorsqu'il est fourni, jamais un détail technique de l'erreur
} as const;

AC-7.14-01 : la page 404 et la limite d'erreur n'affichent jamais de détail technique (message d'exception, pile d'appels) au visiteur ; le texte ci-dessus est fixe, quel que soit l'incident réel. digestLabel n'affiche qu'un identifiant opaque, jamais un message d'exception. AC-7.14-02 : la page 404 et la limite d'erreur offrent toutes deux un moyen de contact (whatsappCtaLabel) en plus du retour à l'accueil, pour qu'un visiteur bloqué puisse joindre Malka directement.

7.15 E-mails #

Fichier : src/lib/email/templates.ts. Les champs mécaniques (envoi, en-têtes, délais, reprise sur erreur) sont définis en §9.5 ; cette section ne couvre que le texte.

7.15.1 E-mail de notification à Malka #

export const notificationEmail = {
  subjectTemplate: 'Nouvelle demande — {offerTitleOrDefault} — {name}', // PROPOSITION — {offerTitleOrDefault} vaut le titre de l’offre concernée ou « Prise de contact » si aucune offre n’est précisée
  textTemplate: `Nouvelle demande de contact — Malka Debleds — Entreprise vivante

Nom : {name}
E-mail : {email}
Téléphone : {phoneOrNotProvided}
Offre concernée : {offerTitleOrNotSpecified}
Situation (boussole d’orientation) : {situationLabelOrNotSpecified}

Message :
{message}

Reçu le {date} à {time} (heure de Paris)
Identifiant de la demande : {requestId}

Pour répondre, utilisez la fonction « Répondre » de votre messagerie : la réponse sera envoyée à {email}.`, // PROPOSITION
  phoneOrNotProvidedFallback: 'Non renseigné', // PROPOSITION
  offerTitleOrNotSpecifiedFallback: 'Non précisée', // PROPOSITION
  situationLabelOrNotSpecifiedFallback: 'Non précisée', // PROPOSITION
} as const;

Le gabarit HTML reprend les mêmes champs dans une structure tabulaire simple (une ligne par champ), avec échappement HTML systématique de toute valeur saisie par le visiteur (mécanique définie en §9.5) — aucun champ saisi n'est jamais interprété comme du HTML.

7.15.2 E-mail d'accusé de réception au visiteur #

export const acknowledgementEmail = {
  subject: 'Votre message a bien été reçu — Malka Debleds', // PROPOSITION
  textTemplate: `Bonjour,

Votre message a bien été reçu. Merci pour votre confiance.

Malka vous répondra personnellement. Si votre demande est urgente, vous pouvez aussi écrire directement sur WhatsApp au 06 67 29 51 79, ou joindre ce même numéro par téléphone.

À très bientôt,
Malka Debleds
accompagnement vibratoire et stratégique des entrepreneurs

astheoris.pro@gmail.com · 06 67 29 51 79`, // PROPOSITION — ne contient aucun champ saisi par le visiteur (anti-abus, voir §9.5)
} as const;

AC-7.15-01 : acknowledgementEmail.textTemplate ne contient aucune interpolation de champ saisi par le visiteur (ni nom, ni message, ni e-mail) — vérifiable statiquement : le gabarit ne référence aucune variable de la soumission. AC-7.15-02 : notificationEmail.textTemplate couvre bien les sept informations requises : nom, e-mail, téléphone, offre, situation, message, horodatage et identifiant de la demande — aucune n'est omise. AC-7.15-03 : le sujet de l'e-mail de notification respecte exactement le gabarit Nouvelle demande — {offerTitleOrDefault} — {name}, y compris la ponctuation (tirets cadratins encadrés d'espaces).

7.16 SEO — titres, méta-descriptions et Open Graph #

Fichier : src/content/fr/seo.ts.

export const seo = {
  siteName: 'Malka Debleds', // FIXE
  locale: 'fr_FR',
  jobTitle: 'Accompagnement vibratoire et stratégique des entrepreneurs', // PROPOSITION — utilisé par le balisage JSON-LD Person (§9/§11)
  defaultOgImageAlt: 'Paysage de montagne au coucher du soleil, univers visuel du site Malka Debleds.', // PROPOSITION — 86 caractères
  routes: {
    home: {
      title: 'Malka Debleds — Entreprise vivante, clarté des entrepreneurs', // PROPOSITION — 60 caractères, contient « Malka Debleds » et « Entreprise vivante »
      description: 'Écoute profonde, méthode en quatre temps et accompagnements sur mesure pour entrepreneurs en quête de clarté, à distance.', // PROPOSITION — 121 caractères
      ogTitle: 'Malka Debleds — Entreprise vivante', // PROPOSITION — 34 caractères
      ogDescription: 'Écoute profonde, méthode en quatre temps et accompagnements sur mesure pour entrepreneurs en quête de clarté, à distance.', // PROPOSITION — identique à description
    },
    mentionsLegales: {
      title: 'Mentions légales — Malka Debleds', // PROPOSITION — 32 caractères
      description: 'Mentions légales du site de Malka Debleds : éditeur, hébergeur, propriété intellectuelle et informations légales du site.', // PROPOSITION — 121 caractères
    },
    politiqueConfidentialite: {
      title: 'Politique de confidentialité — Malka Debleds', // PROPOSITION — 44 caractères
      description: 'Politique de confidentialité du site de Malka Debleds : données collectées, finalités, durée de conservation et droits.', // PROPOSITION — 119 caractères
    },
    notFound: {
      title: 'Page introuvable — Malka Debleds', // PROPOSITION — 32 caractères
      description: 'Cette page n’existe pas ou a été déplacée. Retrouvez l’accueil du site de Malka Debleds, accompagnement des entrepreneurs à distance.', // PROPOSITION — 133 caractères
    },
  },
} as const;

Toutes les longueurs ci-dessus ont été vérifiées caractère par caractère (accents compris) : chaque title compte entre 20 et 60 caractères, chaque description entre 110 et 160 caractères. La page 404 et les pages légales reçoivent une directive noindex (mécanique définie en §11) ; leur titre et leur description restent utiles pour l'onglet du navigateur et les partages ponctuels.

AC-7.16-01 : chaque title des routes ci-dessus compte entre 20 et 60 caractères et chaque description entre 110 et 160 caractères, espaces et ponctuation compris ; routes.home.title contient à la fois « Malka Debleds » et « Entreprise vivante ». AC-7.16-02 : ogTitle/ogDescription sont définis pour la page d'accueil ; les autres routes réutilisent title/description comme valeurs Open Graph par défaut (pas de duplication de texte à maintenir). AC-7.16-03 : defaultOgImageAlt décrit un coucher de soleil (et non un lever), cohérent avec le visuel du hero (§7.4, §1).

7.17 Textes alternatifs des images #

Emplacement Champ consommateur Texte alternatif Statut
Portrait du hero hero.ts (référence d’image, alt défini ici) « Portrait de Malka Debleds » PROPOSITION
Portrait « à propos » about.ts (référence d’image, alt défini ici) « Malka Debleds, portrait de la section À propos » PROPOSITION
Paysage du hero — couche lointaine (ciel et sommets) hero.ts alt="" (décorative) PROPOSITION
Paysage du hero — couche intermédiaire (brume de vallée) hero.ts alt="" (décorative) PROPOSITION
Paysage du hero — couche proche (crête) hero.ts alt="" (décorative) PROPOSITION
Paysage du hero — version mobile aplatie hero.ts alt="" (décorative) PROPOSITION
Logo SVG (si Malka en fournit un, voir §1 — éléments fournis par Malka — et §6.1.3) navigation.ts « Malka Debleds » PROPOSITION
Image Open Graph par défaut seo.ts → defaultOgImageAlt « Paysage de montagne au coucher du soleil, univers visuel du site Malka Debleds. » PROPOSITION

Le décor du footer (montagnes et rivière) est entièrement vectoriel (SVG) : il ne figure pas dans ce tableau de textes alternatifs photographiques ; son caractère décoratif (aria-hidden="true") est traité avec le reste du décor du footer.

Règles :

  • Les deux portraits (hero, à propos) sont les seules images porteuses de sens informatif : leur texte alternatif reste neutre, ne décrit aucun fait vérifiable au-delà de ce que montre l'image (pas de métier, pas de qualificatif), et ne change pas entre l'actif réel et l'actif de secours en développement (public/images/dev-fallback/), afin qu'aucune chaîne ne dépende de l'environnement de build.
  • Toutes les images de paysage du hero, qu'elles soient réelles ou de secours, sont strictement décoratives : alt="" et, lorsque la structure du composant le permet, role="presentation" ou aria-hidden="true" sur leur conteneur pour éviter toute annonce redondante à un lecteur d'écran.
  • Le mot-marque « CLARTÉ » (hero et footer) n'est jamais une image : c'est du texte réel, masqué aux technologies d'assistance via aria-hidden="true" (§7.4), donc hors du périmètre des textes alternatifs.

AC-7.17-01 : aucune image de paysage du hero ne porte de texte alternatif non vide — vérification automatisable par une règle d'accessibilité (axe-core, voir §15) qui échoue si une image purement décorative porte un alt renseigné. AC-7.17-02 : les deux portraits (hero, à propos) portent chacun un texte alternatif non vide et distinct l'un de l'autre, qu'ils affichent l'actif réel ou l'actif de secours en développement.

8. Système d'animation Motion #

Cette section est la source unique pour les tokens de mouvement (durées, courbes, ressorts, décalages, distances), les variants, les hooks, les déclencheurs, la chorégraphie de chaque section et les limites de performance. Les autres sections décrivent ce qui est affiché (§6, §7) ; ici on décrit comment cela bouge. Les couleurs, rayons, typographies et la grille restent définis en §5 ; les versions de paquets sont fixées en §9.1 ; les cibles Core Web Vitals globales sont en §11 ; la matrice de tests globale est en §15.

Convention de lecture : « profil full », « profil lite » et « profil reduced » désignent les trois profils de mouvement définis en §8.1.4 et détaillés en §8.6.

8.1 Principes #

8.1.1 Principes directeurs #

# Principe Traduction concrète
P1 Organique Courbes douces à décélération longue (easeClarte), ressorts légèrement sous-amortis pour l'aiguille de la boussole, dérives courtes (5 s au plus, un seul cycle) pour les halos, la brume et le miroitement de la rivière. Aucun mouvement linéaire, sauf le spinner de chargement.
P2 Discret Amplitudes courtes : 8 à 24 px de translation pour le contenu, jamais plus de 24 px (changement de panneau, révélation du footer). Parallaxe : 120 px maximum, atteints uniquement par la couche la plus lointaine (les autres couches restent à 64 px ou moins), réservée aux écrans ≥ 1024 px à pointeur fin sans réduction de mouvement ; aucune parallaxe pointeur. Aucun effet qui attire l'œil plus que le contenu.
P3 Fonctionnel Chaque animation répond à une question : où regarder maintenant ? (révélation), qu'est-ce qui a changé ? (changement de panneau, résultat d'orientation, succès du formulaire), où suis-je ? (section active, aiguille), est-ce cliquable ? (survol, appui). Une animation sans réponse à l'une de ces questions est supprimée.
P4 Contenu d'abord Tout le contenu textuel est présent dans le HTML rendu par le serveur et lisible sans JavaScript. Le mouvement est une couche de finition : s'il échoue, la page reste complète.
P5 Respect de la personne prefers-reduced-motion, les appareils tactiles, les économiseurs de données et les machines modestes obtiennent une version simplifiée, jamais une version cassée (§8.6).
P6 Compositeur uniquement Seules les propriétés animées sur le compositeur du navigateur sont autorisées (§8.1.3).
P7 Un seul propriétaire par propriété Sur un même élément DOM, une propriété (transform, opacity, clip-path, filter) n'est animée que par un seul système (CSS ou Motion). Quand deux effets doivent se cumuler, on imbrique deux éléments (voir la couche de parallaxe qui enveloppe une couche à séquence CSS, §8.5.1).

8.1.2 Ce qui ne s'anime jamais #

Élément Raison
Texte courant des pages légales (/mentions-legales, /politique-de-confidentialite), page 404, page d'erreur Lisibilité et conformité : seule la barre de navigation conserve son apparition (§8.5.2). Aucune révélation au scroll sur ces pages.
Prix des offres, libellés des CTA, numéro de téléphone, adresse e-mail Information transactionnelle : jamais de compteur, de défilement ni d'apparition retardée. Seule exception de chiffres animés : les chiffres de confiance du hero (CountUp, §8.4.8).
Champs de formulaire pendant la saisie, curseur, placeholder, étiquettes Aucun mouvement ne doit accompagner la frappe. Pas de « secousse » (shake) sur erreur : le message d'erreur apparaît en fondu, rien ne bouge.
Anneau de focus (:focus-visible) Il apparaît instantanément (aucune transition).
Largeur, hauteur, marges, positions (top, left, right, bottom), grid-template-*, font-size, letter-spacing, line-height Provoquent du recalcul de mise en page (§8.1.3).
Défilement de la page (scroll-jacking), curseur personnalisé, préchargeur plein écran, transitions de page entre routes Hors périmètre : nuisent à l'accessibilité, à l'INP et à la confiance.
Autoplay du slider de témoignages Interdit (WCAG 2.2.2) : le slider ne change que sur action de l'utilisateur.
Contenu au-dessus de la ligne de flottaison au moment où il est déjà visible lors de l'hydratation Il reste statique (règle static, §8.4.3) pour éviter tout clignotement.
Photographies de Malka (déformation, flou, filtre de couleur animé) Les portraits sont seulement translatés/révélés ; jamais déformés, floutés ni recolorés.

8.1.3 Propriétés autorisées et interdites #

Statut Propriétés
Autorisées à animer transform (x, y, scale, rotate, scaleX, scaleY), opacity, clip-path (inset() uniquement, sur de petites surfaces), filter: blur() (≤ 8 px, profil full uniquement, un seul usage en v1 : voir §8.5.3 ; remis à none en fin d'animation).
Interdites width, height, min-*/max-*, margin, padding, top/left/right/bottom/inset, font-*, letter-spacing, line-height, border-width, box-shadow animée (l'ombre est une couche pré-rendue dont on anime l'opacity), background-position, layout/layoutId de Motion, drag.
Interdites en transition color, background-color, border-color : les changements d'état de couleur sont instantanés ou réalisés par une couche superposée dont on anime l'opacity (classe .motion-fill, §8.3.5).
Exception encadrée Un indicateur de chargement fonctionnel (spinner d'envoi du formulaire) peut tourner en continu (transform: rotate, CSS) tant que l'envoi est en cours ; il est remplacé par du texte statique en profil reduced (§8.5.9).

Un test statique (§8.8.2) échoue si un variant ou une classe d'animation touche une propriété interdite.

8.1.4 Profils de mouvement #

Trois profils, calculés côté client par useMotionProfile() (§8.4.2) et reflétés sur <html data-motion-profile="full|lite|reduced"> après hydratation :

Profil Condition Principe
reduced (prefers-reduced-motion: reduce) Aucune translation, aucun scale, aucune parallaxe, aucune boucle. Apparition par fondu ≤ 0,2 s ou instantanée. Prioritaire sur tous les autres profils.
lite Écran < 1024 px ou (pointer: coarse) ou navigator.connection.saveData === true ou navigator.deviceMemory <= 2 Distances divisées par 2, durées × 0,7, aucune parallaxe, aucune boucle ambiante, aucun flou. Les révélations simples, la boussole et les transitions de panneau restent actives.
full (min-width: 1024px) and (pointer: fine), sans saveData, sans prefers-reduced-motion Expérience complète décrite en §8.5.

Rendu serveur : le profil du serveur est toujours lite (valeur de repli la moins coûteuse). Aucun rendu serveur ne dépend du profil (§8.2.5) : le profil réel est connu après hydratation, sans décalage visuel.

Le changement de profil ne remonte jamais un arbre React. Les composants rendent toujours les mêmes éléments m.* dans tous les profils ; le profil ne fait varier que les valeurs (jeu de variants, MotionValue de parallaxe qui restent à 0 lorsque la parallaxe est désactivée, durées). Aucun composant ne renvoie un type d'élément différent selon le profil (§8.4.6).

8.2 Architecture #

8.2.1 Dépendances et règles d'import #

  • Paquet : motion (ligne majeure : voir §9.1). Imports uniquement depuis motion/react. Le paquet framer-motion n'est pas installé.
  • Composants animés : toujours m.* (jamais motion.*), sous LazyMotion avec le jeu de fonctionnalités domAnimation et l'option strict (une utilisation de motion.* lève une erreur en développement).
  • domMax est interdit en v1 (il n'apporte que layout et drag, tous deux bannis, §8.1.3). Si une évolution future l'exige, il sera chargé dynamiquement pour le seul composant concerné.
  • animate() (fonction hybride), useAnimationFrame, motion (composant complet) et framer-motion sont interdits par ESLint ; les compteurs utilisent runTween (§8.4.8).

Ces règles sont appliquées par la configuration ESLint unique décrite en §12.8.1 (aucune configuration ESLint n'est définie en §8) :

  • framer-motion est interdit à l'import ;
  • depuis motion/react, les noms motion, domMax, domAnimation, animate et useAnimationFrame sont interdits, domAnimation n'étant autorisé que dans le fichier du fournisseur racine (src/components/layout/providers.tsx, §8.2.3) ;
  • aucun addEventListener('scroll' | 'wheel' | 'touchmove' | 'mousewheel', …) : utiliser useScroll ou IntersectionObserver (§8.7.3).

8.2.2 Arborescence #

src/
├─ styles/
│  └─ motion.css                    # tokens CSS, keyframes de séquence hero, ambiances, micro-interactions, print
├─ lib/
│  ├─ cn.ts                         # cn() = twMerge(clsx(...)) — unique définition (§12.6)
│  └─ motion/                       # modules PURS : aucun hook React, importables côté serveur
│     ├─ tokens.ts                  # DURATION, EASE, SPRING, STAGGER, DISTANCE, PARALLAX_LAYERS, MEDIA…
│     ├─ profile.ts                 # type MotionProfile + resolveProfile()
│     ├─ variants.ts                # REVEAL_SETS, staggerContainer, swapVariants, MENU_PANEL, MENU_LINKS
│     ├─ hero-sequence.ts           # HERO_SEQUENCE + seqStyle() (serveur-compatible, sans hook)
│     ├─ geometry.ts                # normalizeAngle(), shortestDelta()
│     ├─ stagger.ts                 # clampStagger()
│     ├─ tween.ts                   # runTween() (compteurs)
│     ├─ inline-emphasis.ts         # parseInlineEmphasis() pour RevealLines/SeqLines
│     └─ index.ts                   # ré-exports publics (tokens, variants, types)
├─ hooks/                           # hooks de mouvement (tous 'use client' par usage)
│  ├─ use-isomorphic-layout-effect.ts
│  ├─ use-hydrated.ts
│  ├─ use-media-query.ts
│  ├─ use-reduced-motion-safe.ts
│  ├─ use-is-desktop-pointer-fine.ts
│  ├─ use-save-data.ts
│  ├─ use-motion-profile.ts
│  ├─ use-in-view-once.ts
│  ├─ use-reveal-phase.ts
│  ├─ use-ambient-state.ts
│  └─ use-section-observer.ts
└─ components/
   ├─ layout/
   │  └─ providers.tsx              # 'use client' — <Providers> : LazyMotion + MotionConfig + data-motion-profile
   └─ motion/                       # primitives client (sauf seq-lines.tsx)
      ├─ reveal.tsx                 # 'use client' — <Reveal>
      ├─ reveal-lines.tsx           # 'use client' — <RevealLines> (Motion, au scroll)
      ├─ seq-lines.tsx              # serveur — <SeqLines> (séquence CSS du hero)
      ├─ stagger-group.tsx          # 'use client' — <StaggerGroup>, <StaggerItem>
      ├─ parallax-layer.tsx         # 'use client' — <ParallaxScene>, <ParallaxLayer>
      ├─ ambient-drift.tsx          # 'use client' — <AmbientDrift>
      ├─ count-up.tsx               # 'use client' — <CountUp>
      ├─ panel-stack.tsx            # 'use client' — <PanelStack>, <PanelStackItem> (boussole de méthode, slider)
      ├─ stable-swap.tsx            # 'use client' — <StableSwap> (résultat d'orientation uniquement)
      └─ compass-needle.tsx         # 'use client' — aiguille à ressort (utilisée par §6.4)

Les autres hooks de src/hooks/ listés en §12.2 (use-active-section.ts, use-header-scroll-state.ts, use-focus-trap.ts, use-scroll-lock.ts, use-swap-direction.ts) sont définis dans la section qui les consomme (§6.1, §6.6) et ne sont pas redéclarés ici.

La fonction cn() est celle de src/lib/cn.ts (twMerge(clsx(...)), §12.6) : aucun composant de components/motion/ ne définit sa propre variante.

Règles de dépendance : les fichiers de src/lib/motion/ ne contiennent aucun hook React et sont importables depuis un composant serveur. Les fichiers de src/hooks/ et de src/components/motion/ marqués 'use client' ne sont importés que depuis des modules client ou depuis des composants serveur qui les rendent comme éléments enfants.

8.2.3 Fournisseur racine #

// src/components/layout/providers.tsx
'use client';

import { LazyMotion, MotionConfig, domAnimation } from 'motion/react';
import { useEffect, type ReactNode } from 'react';
import { DURATION, EASE } from '@/lib/motion/tokens';
import { useMotionProfile } from '@/hooks/use-motion-profile';

export function Providers({ children }: { children: ReactNode }) {
  const profile = useMotionProfile();

  useEffect(() => {
    document.documentElement.dataset.motionProfile = profile;
  }, [profile]);

  return (
    <LazyMotion features={domAnimation} strict>
      <MotionConfig
        reducedMotion="user"
        transition={{ duration: DURATION.base, ease: [...EASE.clarte] }}
      >
        {children}
      </MotionConfig>
    </LazyMotion>
  );
}

Placement dans src/app/layout.tsx : <body><Providers>{/* skip link, <SiteHeader/>, {children}, <FooterSection/> */}</Providers></body>. Providers est le seul client component à envelopper l'arbre ; les enfants restent des composants serveur (le provider les reçoit en children).

Décisions :

  1. domAnimation est importé de façon synchrone (pas de features={() => import(...)}) : le hero ne dépend pas de Motion (séquence CSS, §8.2.6) et le coût (≈ 20 Ko gzip, plafond de 22 Ko en §8.7) est budgété dans la limite de JavaScript de la page (§11). Un chargement asynchrone compliquerait la mécanique de « mise en armement » des révélations sans gain mesurable.
  2. MotionConfig reducedMotion="user" est un filet de sécurité : il neutralise automatiquement les animations de transformation si un m.* n'a pas de variant reduced. Il ne remplace pas les variants reduced explicites (§8.3.4) qui fixent la durée ≤ 0,2 s.
  3. La transition par défaut du MotionConfig (base + easeClarte) est un repli ; chaque variant déclare sa propre transition.
  4. CSP : Motion et les variables --seq-* posent des styles en ligne (attribut style rendu côté serveur pour la séquence du hero). La politique de sécurité de contenu (§10) doit donc autoriser style-src-attr 'unsafe-inline' (aucune directive supplémentaire pour les scripts).

8.2.4 Frontières 'use client' #

Fichier / composant Directive Raison
Tous les fichiers de src/lib/motion/ (tokens.ts, variants.ts, hero-sequence.ts, geometry.ts, stagger.ts, tween.ts, inline-emphasis.ts…) aucune Données et fonctions pures.
seq-lines.tsx, SeqLines, hero (structure, texte, images) aucune (serveur) Le HTML du hero est entièrement rendu par le serveur ; l'animation d'entrée est CSS.
layout/providers.tsx, reveal.tsx, reveal-lines.tsx, stagger-group.tsx, parallax-layer.tsx, ambient-drift.tsx, count-up.tsx, panel-stack.tsx, stable-swap.tsx, compass-needle.tsx, et les hooks de src/hooks/ 'use client' Hooks, m.*, observateurs.
Sections de §6 (Philosophie, Offres, À propos…) serveur Elles composent <Reveal>, <StaggerGroup>, <RevealLines> autour de leur contenu serveur.
Boussole (§6.4), orientation (§6.6), slider (§6.8), formulaire (§6.9), en-tête (§6.1) client Interactivité ; ils consomment PanelStack (boussole de méthode, slider), StableSwap (orientation seulement), CompassNeedle, useSectionObserver, etc.

Props passées d'un composant serveur à un composant du dossier motion/ : uniquement des valeurs sérialisables (chaînes, nombres, booléens, tableaux de chaînes, éléments React). Jamais de fonction : StableSwap (qui reçoit une fonction render) n'est instancié que depuis un composant client (la boussole d'orientation, §6.6).

8.2.5 Règles de rendu serveur et d'hydratation #

# Règle
R-SSR-1 Le HTML serveur contient tout le texte, avec ses styles finaux. Aucun style="opacity:0", aucun initial={{ opacity: 0 }} rendu côté serveur. Sans JavaScript, la page est complète et lisible.
R-SSR-2 Les primitives de révélation (Reveal, StaggerGroup, RevealLines) rendent, au premier rendu (serveur et première passe client), sans prop animate ni initial : aucun style de mouvement n'existe avant l'« armement » (§8.4.3). Les composants à état (StableSwap, PanelStack, CompassNeedle) utilisent initial={false} : leur HTML serveur est directement dans l'état final.
R-SSR-3 L'armement (passage à l'état caché) n'a lieu qu'après hydratation, dans un effet de layout, et uniquement pour les éléments situés entièrement sous le bas du viewport. Un élément déjà (même partiellement) visible, ou déjà dépassé, passe en static : il n'est jamais masqué.
R-SSR-4 Aucun composant ne lit window, matchMedia, navigator pendant le rendu. Les hooks s'appuient sur useSyncExternalStore avec un getServerSnapshot constant.
R-SSR-5 Les éléments LCP ne portent aucun mouvement qui masque leur peinture : voir §8.2.6.
R-SSR-6 Impression et robots : @media print force l'état final (§8.3.5) ; le contenu masqué par Motion reste dans le DOM (jamais display: none).
R-SSR-7 Le rendu serveur ne varie pas selon le profil : zéro écart d'hydratation attendu. Tout avertissement d'hydratation lié au mouvement est une anomalie bloquante (§8.9, AC-8.2-03).
R-SSR-8 Atterrissage avec ancre (/#a-propos) ou restauration du scroll : les éléments déjà dépassés ou visibles passent en static, ceux situés plus bas sont armés normalement.

8.2.6 Révélation du hero sans retarder le LCP #

Décision : la séquence d'entrée du hero est pilotée par CSS (animations @keyframes, animation-fill-mode: backwards), pas par Motion. Elle démarre à la première peinture, sans attendre JavaScript ni l'hydratation, et laisse le HTML serveur intact. Motion ne prend le relais qu'après la séquence, pour le scroll (parallaxe) et la souris.

  1. Élément LCP : on ne fige pas l'élément. Sur ordinateur (≥ 1024 px) il s'agit de l'image de la couche lointaine hero-far, rendue par next/image (preload et fetchPriority="high" : c'est la seule image du hero préchargée ; attribut data-lcp="hero-landscape"). Sous 1024 px le texte précède l'image : le candidat LCP est le <h1> ; l'image de la bande cadrée porte data-lcp="hero-band" sans préchargement ni priorité. L'image de la couche lointaine est peinte opaque et à sa place dès la première frame ; aucune image du hero n'est jamais animée en opacity (un élément à opacity: 0 n'est pas candidat LCP).
  2. Fondu du paysage : réalisé par un voile forest-950 (data-seq="veil", aria-hidden) posé (couche --z-hero-veil, §5.7.4) au-dessus des couches de paysage, du mot CLARTÉ, du halo, du portrait et du premier plan, et sous le grain et le contenu textuel (§8.5.1). Son opacity passe de 1 à 0 en 0,9 s (slow). Le voile a la même couleur que le fond du hero : l'œil perçoit un fondu à partir du vert profond, sans flash blanc. La durée de 0,9 s est un choix assumé : le LCP est enregistré à la première peinture de l'image, le voile ne retarde donc aucune métrique et la lenteur du fondu préserve une apparition douce. Aucun contenu textuel ou interactif n'est situé sous le voile. Son style de repos (sans animation) est opacity: 0; visibility: hidden : si l'animation CSS est supprimée, le voile ne recouvre rien (§8.3.5).
  3. Titre <h1> : présent dans le HTML serveur sous forme de lignes <span> masquées (overflow: hidden). Chaque ligne interne (data-seq="line") part de translateY(105%) et revient à sa place ; la première ligne démarre à 100 ms (jamais plus tard). Le texte du <h1> ne se déplace ni ne se fond jamais lors du défilement (§8.5.1) : sa seule animation est cette séquence d'entrée CSS.
  4. Garde-fous : si l'animation CSS est supprimée (CSS désactivé, animation: none, prefers-reduced-motion), l'état par défaut des éléments est l'état final visible. Le fond forest-950 du hero reste affiché même si l'image échoue.
  5. Mesure obligatoire : un test Playwright (§8.8.4) relève l'élément LCP via PerformanceObserver et vérifie que c'est soit un élément portant [data-lcp], soit le <h1>, que sa valeur respecte la cible de §11 (≤ 2,5 s) et qu'il n'a jamais eu opacity: 0.

8.2.7 Synchronisation avec le cycle de vie React #

  1. Effets et nettoyage : tout observateur (IntersectionObserver), écouteur (animationstart, visibilitychange, change de matchMedia), requestAnimationFrame ou setTimeout créé dans un useEffect est libéré dans sa fonction de nettoyage. Les hooks doivent résister au double montage de React.StrictMode (monter → démonter → remonter) sans fuite ni état incohérent.
  2. Aucun setState par frame : les valeurs animées passent par des MotionValue (useScroll, useTransform, useSpring) qui écrivent directement dans le style. Seuls changements d'état discrets autorisés : phase de révélation, section active, état de l'en-tête (top / scrolled, §6.1), valeur entière du compteur (≤ 300 changements sur 0,9 s).
  3. onAnimationComplete : utilisé uniquement pour des tâches non critiques (remettre filter: none, retirer un attribut data-*). Aucune logique métier (focus, soumission, navigation) n'en dépend.
  4. Focus : le déplacement du focus (ouverture/fermeture du menu mobile, panneau de succès du formulaire) est exécuté immédiatement au changement d'état, jamais après la fin d'une animation.
  5. Interruption : toute animation peut être interrompue par la suivante (clics rapides sur les étapes de la boussole, flèches du slider) ; la dernière action gagne, il n'y a pas de file d'attente. Un test E2E enchaîne 6 clics en moins d'une seconde (AC-8.5-14).
  6. Changement de préférence à chaud : si l'utilisateur active/désactive la réduction de mouvement pendant la visite, useMotionProfile() se met à jour (abonnement change), les animations en cours se terminent naturellement, les suivantes utilisent le nouveau profil.
  7. Navigation : pas de transition entre routes. Au passage de / à une page légale, le montage/démontage des composants libère tous les observateurs (aucune animation orpheline).
  8. bfcache : aucun état de mouvement n'est persisté ; au retour via l'historique, la page se réaffiche à son état final.

8.2.8 AnimatePresence, modes et clés #

Cas Mécanisme Mode Clé Justification
Panneau du menu mobile (§6.1) AnimatePresence sync (défaut) constante "menu-mobile" Un seul enfant ; la sortie (0,15 s) est plus rapide que l'entrée (0,25 s).
Bannière d'erreur du formulaire (§6.9) AnimatePresence wait code d'erreur (error.code) Deux erreurs successives ne se superposent pas.
Panneaux d'explication de la boussole (§6.4) Aucun AnimatePresence : les quatre panneaux restent montés dans une seule cellule de grille (§8.4.10) — id d'étape (ecouter, explorer, clarifier, decider) Les panneaux role="tabpanel" restent dans le DOM (cibles d'aria-controls, texte indexable) ; le panneau actif s'anime en opacity (+ y 8 px), les autres sont visibility: hidden et inert.
Résultat de la boussole d'orientation (§6.6) StableSwap popLayout id de situation ou "idle" Seul cas d'usage de StableSwap : un seul résultat monté, hauteur réservée.
Slider de témoignages (§6.8) Aucun AnimatePresence : toutes les citations restent montées dans une seule cellule de grille (§8.4.10) — testimonial.id Les citations restent dans le DOM (indexables) ; la citation active s'anime en opacity (+ y 12 px), les autres sont visibility: hidden et inert.
Succès du formulaire (§6.9) Permutation formulaire → panneau de succès avec hauteur figée (§8.5.9) — — Évite tout décalage de mise en page.

Règles de clés : la clé d'un enfant d'AnimatePresence est un identifiant métier stable (id d'étape, id de témoignage), jamais un index de tableau ni Math.random(). Un changement de clé = une sortie + une entrée ; un changement de contenu sous la même clé n'anime pas.

Le composant StableSwap (§8.4.10) garantit que le conteneur occupe en permanence la hauteur du plus grand résultat d'orientation ; pour la boussole de méthode et le slider, la hauteur est celle de la cellule de grille commune aux panneaux montés (aucun décalage de mise en page pendant ou après le changement).

8.3 Tokens #

8.3.1 Fichier src/lib/motion/tokens.ts #

// src/lib/motion/tokens.ts — aucune dépendance React ; importable côté serveur.
import type { Transition } from 'motion/react';

/** Durées en secondes (Motion). Équivalent CSS : --motion-dur-* en millisecondes. */
export const DURATION = {
  instant: 0.15,
  fast: 0.25,
  base: 0.5,
  slow: 0.9,
  hero: 1.4,
} as const;
export type DurationToken = keyof typeof DURATION;

/** Durée maximale de tout fondu en profil reduced. */
export const REDUCED_FADE_S = 0.2;

export type Bezier = readonly [number, number, number, number];
export const EASE = {
  clarte: [0.22, 1, 0.36, 1],
  soft: [0.33, 1, 0.68, 1],
} as const satisfies Record<string, Bezier>;
export type EaseToken = keyof typeof EASE;

/** Ressorts. `micro` est le ressort générique des micro-interactions. */
export const SPRING = {
  micro: { type: 'spring', stiffness: 300, damping: 30 },
  needle: { type: 'spring', stiffness: 140, damping: 16, mass: 1 }, // sous-amorti : léger dépassement (~5 %)
  pop: { type: 'spring', stiffness: 400, damping: 24 },             // icône de succès
} as const satisfies Record<string, Transition>;

/** Décalages entre éléments successifs (secondes). */
export const STAGGER = {
  lines: 0.08,   // lignes d'un titre
  cards: 0.1,    // cartes (offres, blocs de contact)
  items: 0.06,   // listes (liens, paragraphes, boutons de situation)
  rail: 0.1,     // étapes du rail de méthode du hero
  maxTotal: 0.6, // plafond du décalage cumulé d'un groupe (voir clampStagger)
} as const;

/** Distances de translation (px). */
export const DISTANCE = { xs: 8, sm: 12, md: 16, lg: 24 } as const;
export type DistanceToken = keyof typeof DISTANCE;

/** Coefficients du profil lite. */
export const LITE = { distanceFactor: 0.5, timeScale: 0.7 } as const;

export const BLUR_MAX_PX = 8;          // profil full uniquement
export const LINE_MASK_OFFSET = '105%'; // départ des lignes masquées (dépasse le masque pour couper jambages et accents)
export const PRESS_SCALE = 0.98;
export const WORD_OPACITY = { hero: 0.14, footer: 0.08 } as const; // opacité de repos du mot CLARTÉ (couleur : §5.4.6)

/** Observation d'entrée dans le viewport pour les révélations. */
export const VIEWPORT = { rootMargin: '0px 0px -10% 0px' } as const;
/** Bande de lecture pour la section active : 10 % de hauteur, entre 40 % et 50 % depuis le haut. */
export const SECTION_OBSERVER = { rootMargin: '-40% 0px -50% 0px' } as const;

export const MEDIA = {
  reduced: '(prefers-reduced-motion: reduce)',
  desktopFine: '(min-width: 1024px) and (pointer: fine)',
} as const;

export const COUNT_UP = { durationMs: { full: 900, lite: 700 } } as const;

/** Slider : geste de balayage sans API drag. */
export const SWIPE = { thresholdPx: 48, horizontalRatio: 1.5 } as const;

/**
 * Durée (secondes) du UNIQUE cycle des ambiances (halos, brume, miroitement de la rivière) ;
 * miroir de motion.css. Chaque ambiance joue un seul cycle par entrée dans le viewport puis revient
 * exactement à son état de repos (WCAG 2.2.2). Toutes les valeurs sont ≤ AMBIENT_MAX_S.
 */
export const AMBIENT_MAX_S = 5;
export const AMBIENT_PERIOD_S = { haloA: 5, haloB: 4.5, mist: 5, shimmer: 4 } as const;

/** Parallaxe : un seul lieu de vérité pour toutes les couches (aucune parallaxe pointeur). */
export const PARALLAX_MAX_PX = 120;

export type ParallaxLayerName =
  | 'heroFar' | 'heroMid' | 'heroNear' | 'heroPortrait' | 'heroWord' | 'heroText'
  | 'footerFar' | 'footerMid';

export interface ParallaxLayerSpec {
  /** [yDébut, yFin] en px, interpolé sur la progression du scroll de la scène (0 → 1). */
  readonly y: readonly [number, number];
}

export const PARALLAX_LAYERS = {
  heroFar:      { y: [0, 120] },
  heroWord:     { y: [0, 48] },
  heroMid:      { y: [0, 64] },
  heroPortrait: { y: [0, 24] },
  heroNear:     { y: [0, 0] },   // premier plan : ne bouge jamais
  heroText:     { y: [0, 0] },   // le texte (h1 compris) ne bouge ni ne se fond jamais
  footerFar:    { y: [24, 0] },
  footerMid:    { y: [12, 0] },
} as const satisfies Record<ParallaxLayerName, ParallaxLayerSpec>;

Aucune couche ne réagit au pointeur : la parallaxe ne dépend que de la progression du défilement. Le mot « CLARTÉ » du footer reste statique (aucune entrée footerWord) et le premier plan du footer ne bouge pas.

Invariants vérifiés par test (§8.8.1) : max(|y|) ≤ PARALLAX_MAX_PX pour toutes les couches ; seule heroFar atteint 120 ; heroText.y et heroNear.y valent [0, 0] ; les valeurs du hero sont exactement heroFar 120, heroMid 64, heroWord 48, heroPortrait 24 ; footerFar 24 et footerMid 12 ; AMBIENT_PERIOD_S : chaque valeur ≤ AMBIENT_MAX_S ; les durées sont strictement croissantes (instant < fast < base < slow < hero) ; toute couche fill a un y ≥ 0 et une extension de remplissage égale à son max(y) (§8.4.6).

8.3.2 Table de référence #

Token Valeur Usage principal
DURATION.instant 0,15 s Sorties de panneau, appui bouton, fondu reduced des permutations
DURATION.fast 0,25 s Survols, entrée de résultat, panneau du menu mobile, bannières
DURATION.base 0,5 s Révélations au scroll, entrée de panneau, éléments secondaires du hero
DURATION.slow 0,9 s Lignes de titre, voile du paysage, portrait, CLARTÉ, portrait « À propos »
DURATION.hero 1,4 s « Respiration » du paysage (scale) et portrait à propos
EASE.clarte [0.22, 1, 0.36, 1] Courbe par défaut de toute entrée
EASE.soft [0.33, 1, 0.68, 1] Survols, fondus de superposition, sorties
SPRING.micro k 300 / c 30 Micro-interactions génériques
SPRING.needle k 140 / c 16 Aiguille de la boussole
SPRING.pop k 400 / c 24 Icône de succès
STAGGER.lines / .cards / .items / .rail 0,08 / 0,1 / 0,06 / 0,1 s Voir §8.5
DISTANCE 8 / 12 / 16 / 24 px xs / sm / md / lg

8.3.3 Miroir CSS des tokens #

Les micro-interactions et la séquence du hero sont en CSS (§8.3.5). Les valeurs sont dupliquées dans src/styles/motion.css ; un test d'égalité (§8.8.1) échoue si elles divergent de tokens.ts.

/* src/styles/motion.css (extrait : tokens) */
:root {
  --motion-dur-instant: 150ms;
  --motion-dur-fast: 250ms;
  --motion-dur-base: 500ms;
  --motion-dur-slow: 900ms;
  --motion-dur-hero: 1400ms;
  --motion-ease-clarte: cubic-bezier(0.22, 1, 0.36, 1);
  --motion-ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
  --clarte-opacity: 0.14;           /* CLARTÉ du hero (couleur et taille : §5.4.6) */
  --clarte-opacity-footer: 0.08;    /* CLARTÉ du footer */
  --seq-scale: 1;                   /* facteur de temps de la séquence hero (0.7 en lite) */
  --seq-dist: 1;                    /* facteur de distance (0.5 en lite) */
  --seq-y: 24px;                    /* distance de montée par défaut */
}

8.3.4 Variants (src/lib/motion/variants.ts) #

Chaque variant existe en trois déclinaisons indexées par profil (full, lite, reduced). Les composants choisissent le jeu avec set[profile].

// src/lib/motion/variants.ts
import type { Transition, Variants } from 'motion/react';
import {
  BLUR_MAX_PX, DISTANCE, DURATION, EASE, LINE_MASK_OFFSET, LITE, REDUCED_FADE_S, STAGGER,
  type DistanceToken,
} from './tokens';
import type { MotionProfile } from './profile';

export type VariantSet = Readonly<Record<MotionProfile, Variants>>;
export interface RevealCustom { delay?: number }

const INSTANT: Transition = { duration: 0 };
const ease = EASE.clarte as unknown as number[];

/** Transition d'entrée standard ; `custom.delay` en secondes. */
const enter = (c: RevealCustom | undefined, duration: number): Transition => ({
  duration, ease, delay: c?.delay ?? 0,
});

const fadeReduced: Variants = {
  hidden: { opacity: 0, transition: INSTANT },
  visible: {
    opacity: 1,
    transition: { duration: REDUCED_FADE_S, ease: 'linear', delay: 0 },
  },
};

function riseSet(token: DistanceToken, duration: number): VariantSet {
  const d = DISTANCE[token];
  const dLite = Math.round(d * LITE.distanceFactor);
  return {
    full: {
      hidden: { opacity: 0, y: d, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ opacity: 1, y: 0, transition: enter(c, duration) }),
    },
    lite: {
      hidden: { opacity: 0, y: dLite, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ opacity: 1, y: 0, transition: enter(c, duration * LITE.timeScale) }),
    },
    reduced: fadeReduced,
  };
}

export const REVEAL_SETS = {
  fade: {
    full: {
      hidden: { opacity: 0, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ opacity: 1, transition: enter(c, DURATION.base) }),
    },
    lite: {
      hidden: { opacity: 0, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ opacity: 1, transition: enter(c, DURATION.base * LITE.timeScale) }),
    },
    reduced: fadeReduced,
  },
  fadeUpSm: riseSet('sm', DURATION.base),
  fadeUp: riseSet('md', DURATION.base),
  fadeUpLg: riseSet('lg', DURATION.base),
  scaleIn: {
    full: {
      hidden: { opacity: 0, scale: 0.96, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ opacity: 1, scale: 1, transition: enter(c, DURATION.slow) }),
    },
    lite: {
      hidden: { opacity: 0, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ opacity: 1, transition: enter(c, DURATION.base * LITE.timeScale) }),
    },
    reduced: fadeReduced,
  },
  /** Filet décoratif qui se « dessine » (scaleX ; appliquer `origin-left`). */
  drawX: {
    full: {
      hidden: { scaleX: 0, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ scaleX: 1, transition: enter(c, DURATION.slow) }),
    },
    lite: {
      hidden: { scaleX: 0, transition: INSTANT },
      visible: (c?: RevealCustom) => ({ scaleX: 1, transition: enter(c, DURATION.slow * LITE.timeScale) }),
    },
    reduced: fadeReduced,
  },
  /** Révélation par rideau (clip-path) : portrait « À propos ». Lite : montée simple. */
  wipeUp: {
    full: {
      hidden: { clipPath: 'inset(100% 0% 0% 0%)', transition: INSTANT },
      visible: (c?: RevealCustom) => ({
        clipPath: 'inset(0% 0% 0% 0%)',
        transition: enter(c, DURATION.slow),
        transitionEnd: { clipPath: 'none' },
      }),
    },
    lite: riseSet('md', DURATION.base).lite,
    reduced: fadeReduced,
  },
  /** Flou → net : un seul usage en v1 (phrase d'accent de la Philosophie, §8.5.3). */
  blurUp: {
    full: {
      hidden: { opacity: 0, y: DISTANCE.md, filter: `blur(${BLUR_MAX_PX}px)`, transition: INSTANT },
      visible: (c?: RevealCustom) => ({
        opacity: 1, y: 0, filter: 'blur(0px)',
        transition: enter(c, DURATION.slow),
        transitionEnd: { filter: 'none' },
      }),
    },
    lite: riseSet('md', DURATION.base).lite,
    reduced: fadeReduced,
  },
} as const satisfies Record<string, VariantSet>;
export type RevealVariantName = keyof typeof REVEAL_SETS;

/** Ligne masquée (RevealLines) : le <span> interne glisse dans son masque. */
export const LINE_SET: VariantSet = {
  full: {
    hidden: { y: LINE_MASK_OFFSET, transition: INSTANT },
    visible: { y: '0%', transition: { duration: DURATION.slow, ease } },
  },
  lite: {
    hidden: { y: LINE_MASK_OFFSET, transition: INSTANT },
    visible: { y: '0%', transition: { duration: DURATION.slow * LITE.timeScale, ease } },
  },
  reduced: fadeReduced,
};

/** Conteneur de groupe : ne porte que le décalage des enfants. */
export function staggerContainer(step: number, delayChildren = 0): Variants {
  return {
    hidden: { transition: INSTANT },
    visible: (c?: RevealCustom) => ({
      transition: { staggerChildren: step, delayChildren: (c?.delay ?? 0) + delayChildren },
    }),
  };
}
export const ZERO_DELAY: RevealCustom = { delay: 0 };

/** Permutation du résultat d'orientation (StableSwap, axe vertical uniquement). */
export function swapVariants(token: DistanceToken, profile: MotionProfile): Variants {
  if (profile === 'reduced') {
    return {
      enter: { opacity: 0 },
      center: { opacity: 1, transition: { duration: DURATION.instant, ease: 'linear' } },
      exit: { opacity: 0, transition: { duration: DURATION.instant, ease: 'linear' } },
    };
  }
  const d = profile === 'lite' ? Math.round(DISTANCE[token] * LITE.distanceFactor) : DISTANCE[token];
  const k = profile === 'lite' ? LITE.timeScale : 1;
  return {
    enter: { opacity: 0, y: d },
    center: {
      opacity: 1, y: 0,
      transition: { duration: DURATION.base * k, ease, delay: 0.05 },
    },
    exit: {
      opacity: 0, y: -d * 0.5,
      transition: { duration: DURATION.fast * k, ease: EASE.soft as unknown as number[] },
    },
  };
}

/**
 * Pile de panneaux toujours montés (boussole de méthode §6.4, slider de témoignages §6.8) :
 * le panneau actif passe à opacity 1 / y 0, les autres à opacity 0 puis `visibility: hidden`
 * (le composant ajoute aussi `inert`). `token` = 'xs' (8 px, boussole) ou 'sm' (12 px, slider).
 */
export function panelStackVariants(token: 'xs' | 'sm', profile: MotionProfile): Variants {
  if (profile === 'reduced') {
    return {
      active: { opacity: 1, visibility: 'visible', transition: { duration: DURATION.instant, ease: 'linear' } },
      inactive: { opacity: 0, transition: INSTANT, transitionEnd: { visibility: 'hidden' } },
    };
  }
  const d = profile === 'lite' ? Math.round(DISTANCE[token] * LITE.distanceFactor) : DISTANCE[token];
  const k = profile === 'lite' ? LITE.timeScale : 1;
  return {
    active: {
      opacity: 1, y: 0, visibility: 'visible',
      transition: { duration: DURATION.base * k, ease, delay: 0.05 },
    },
    inactive: {
      opacity: 0, y: d,
      transition: { duration: DURATION.fast * k, ease: EASE.soft as unknown as number[] },
      transitionEnd: { visibility: 'hidden' },
    },
  };
}

/** Panneau plein écran du menu mobile (§8.5.2) : ouverture en fondu + montée de -12 px à 0 en `fast` ; fermeture en fondu seul en `instant`. */
export const MENU_PANEL: VariantSet = {
  full: {
    closed: { opacity: 0, y: -DISTANCE.sm },
    open: { opacity: 1, y: 0, transition: { duration: DURATION.fast, ease } },
    exit: { opacity: 0, transition: { duration: DURATION.instant, ease: EASE.soft as unknown as number[] } },
  },
  lite: {
    closed: { opacity: 0, y: -DISTANCE.sm },
    open: { opacity: 1, y: 0, transition: { duration: DURATION.fast * LITE.timeScale, ease } },
    exit: { opacity: 0, transition: { duration: DURATION.instant, ease: EASE.soft as unknown as number[] } },
  },
  // reduced : apparition et disparition instantanées, sans translation
  reduced: {
    closed: { opacity: 0, y: 0 },
    open: { opacity: 1, y: 0, transition: INSTANT },
    exit: { opacity: 0, y: 0, transition: INSTANT },
  },
};
export const MENU_LINKS: VariantSet = {
  full: {
    closed: { opacity: 0, y: DISTANCE.sm },
    open: (i: number) => ({
      opacity: 1, y: 0,
      transition: { duration: DURATION.base, ease, delay: 0.15 + i * STAGGER.items },
    }),
  },
  lite: {
    closed: { opacity: 0, y: DISTANCE.xs },
    open: (i: number) => ({
      opacity: 1, y: 0,
      transition: { duration: DURATION.fast, ease, delay: 0.1 + i * STAGGER.items },
    }),
  },
  reduced: { closed: { opacity: 1 }, open: { opacity: 1, transition: INSTANT } },
};

Propriétés autorisées dans les objets de variants : opacity, x, y, scale, rotate, scaleX, scaleY, clipPath, filter, visibility (uniquement pour masquer un panneau inactif après son fondu), transition, transitionEnd (test §8.8.1).

8.3.5 Feuille de style src/styles/motion.css #

Importée une fois depuis src/styles/globals.css (@import './motion.css';). Elle contient : les tokens CSS (§8.3.3), la séquence du hero, les ambiances (un cycle ≤ 5 s), la pile de panneaux, les micro-interactions et les règles print/reduced.

/* ───── 1. Séquence d'entrée du hero (CSS, première peinture) ───── */
@media (max-width: 1023.98px), (pointer: coarse) {
  :root { --seq-scale: 0.7; --seq-dist: 0.5; }
}

[data-seq] {
  animation-duration: calc(var(--seq-dur, 500ms) * var(--seq-scale));
  animation-delay: calc(var(--seq-delay, 0ms) * var(--seq-scale));
  animation-timing-function: var(--motion-ease-clarte);
  animation-fill-mode: backwards;   /* état « from » pendant le délai ; état naturel (final) ensuite */
}
[data-seq='rise']   { animation-name: seq-rise; }
[data-seq='fade']   { animation-name: seq-fade; animation-timing-function: var(--motion-ease-soft); }
[data-seq='line']   { animation-name: seq-line; }
[data-seq='word']   { animation-name: seq-word; }
[data-seq='veil']   { animation-name: seq-veil; animation-timing-function: var(--motion-ease-soft); opacity: 0; visibility: hidden; } /* repos = voile retiré */
[data-seq='settle'] { animation-name: seq-settle; animation-timing-function: var(--motion-ease-soft); }
[data-seq='draw']   { animation-name: seq-draw; transform-origin: top center; }

@keyframes seq-rise   { from { opacity: 0; transform: translate3d(0, calc(var(--seq-y, 24px) * var(--seq-dist)), 0); } to { opacity: 1; transform: none; } }
@keyframes seq-fade   { from { opacity: 0; } to { opacity: 1; } }
@keyframes seq-line   { from { transform: translate3d(0, 105%, 0); } to { transform: none; } }
@keyframes seq-word   { from { opacity: 0; transform: scale(1.03); } to { opacity: var(--clarte-opacity); transform: none; } }
@keyframes seq-fade-word { from { opacity: 0; } to { opacity: var(--clarte-opacity); } }
@keyframes seq-veil   { from { opacity: 1; visibility: visible; } to { opacity: 0; visibility: hidden; } }
@keyframes seq-settle { from { transform: scale(1.06); } to { transform: none; } }
@keyframes seq-draw   { from { transform: scaleY(0); } to { transform: none; } }
@keyframes seq-draw-x { from { transform: scaleX(0); } to { transform: none; } }

/* Rail horizontal sous 1280 px : le trait se dessine de gauche à droite */
@media (max-width: 1279.98px) { [data-seq='draw'] { animation-name: seq-draw-x; transform-origin: left center; } }

/* Mobile / tactile : pas de « respiration » du paysage, CLARTÉ en fondu simple */
@media (max-width: 1023.98px), (pointer: coarse) {
  [data-seq='settle'] { animation: none; }
  [data-seq='word']   { animation-name: seq-fade-word; }
}

/* ───── 2. Ambiances : UN seul cycle ≤ 5 s par entrée dans le viewport (pilotées par <AmbientDrift>) ───── */
.ambient {
  animation-name: none;                          /* état `idle` : aucune animation */
  animation-duration: var(--ambient-period, 5s); /* toujours ≤ 5 s */
  animation-timing-function: ease-in-out;
  animation-iteration-count: 1;                  /* jamais de boucle infinie (WCAG 2.2.2) */
  animation-fill-mode: none;                     /* fin de cycle = retour exact à l'état de repos */
}
.ambient[data-ambient-state='running'], .ambient[data-ambient-state='paused'] { animation-name: var(--ambient-name); }
.ambient[data-ambient-state='paused'] { animation-play-state: paused; } /* onglet masqué */
.ambient-halo-a  { --ambient-period: 5s;   --ambient-name: ambient-halo-a; }
.ambient-halo-b  { --ambient-period: 4.5s; --ambient-name: ambient-halo-b; }
.ambient-mist    { --ambient-period: 5s;   --ambient-name: ambient-mist; }
.ambient-shimmer { --ambient-period: 4s;   --ambient-name: ambient-shimmer; }
/* Symétriques : 0 % et 100 % = état de repos de l'élément */
@keyframes ambient-halo-a  { 0%, 100% { transform: none; opacity: 1; } 50% { transform: translate3d(14px, -10px, 0) scale(1.04); opacity: .85; } }
@keyframes ambient-halo-b  { 0%, 100% { transform: none; opacity: 1; } 50% { transform: translate3d(-16px, 8px, 0) scale(1.03); opacity: .8; } }
@keyframes ambient-mist    { 0%, 100% { transform: none; opacity: 1; } 50% { transform: translate3d(24px, 0, 0); opacity: .7; } }
@keyframes ambient-shimmer { 0%, 100% { transform: none; opacity: .5; } 50% { transform: translate3d(20%, 0, 0); opacity: .9; } }
/* Mobile / tactile : aucune ambiance (voir §8.6) */
@media (max-width: 1023.98px), (pointer: coarse) { .ambient { animation: none !important; } }

/* ───── 3. Micro-interactions (survol uniquement sur pointeur fin) ───── */
.motion-press { transition: transform var(--motion-dur-instant) var(--motion-ease-soft); }
.motion-press:active { transform: scale(0.98); }

/* Remplissage de survol par couche superposée (aucune transition de couleur) */
.motion-fill { position: relative; isolation: isolate; overflow: hidden; }
.motion-fill::before {
  content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit;
  background: var(--motion-fill-color, currentColor); opacity: 0;
  transition: opacity var(--motion-dur-fast) var(--motion-ease-soft);
}

.motion-lift { position: relative; transition: transform var(--motion-dur-base) var(--motion-ease-clarte); }
.motion-lift::after {                 /* ombre pré-rendue : seule son opacité change */
  content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;
  box-shadow: var(--shadow-elev-4); opacity: 0;
  transition: opacity var(--motion-dur-base) var(--motion-ease-clarte);
}

.motion-arrow { transition: transform var(--motion-dur-fast) var(--motion-ease-clarte); }

.motion-underline { position: relative; }
.motion-underline::after {
  content: ''; position: absolute; inset-inline: 0; bottom: -2px; height: 1px; background: currentColor;
  transform: scaleX(0); transform-origin: left center;
  transition: transform var(--motion-dur-fast) var(--motion-ease-clarte);
}
.motion-underline[aria-current='location']::after { transform: scaleX(1); }

/* Barre verticale de 2 px au survol d'une ligne éditoriale (offres) */
.motion-bar { position: relative; }
.motion-bar::before {
  content: ''; position: absolute; inset-block: 0; inset-inline-start: 0; width: 2px; background: var(--accent, currentColor);
  transform: scaleY(0); transform-origin: top center;
  transition: transform var(--motion-dur-fast) var(--motion-ease-clarte);
}

@media (hover: hover) and (pointer: fine) {
  .motion-fill:hover::before { opacity: var(--motion-fill-opacity, 0.14); }
  .motion-lift:hover { transform: translate3d(0, -4px, 0); }
  .motion-lift:hover::after { opacity: 1; }
  a:hover .motion-arrow, button:hover .motion-arrow { transform: translate3d(3px, 0, 0); }
  .motion-underline:hover::after { transform: scaleX(1); }
  .motion-bar:hover::before { transform: scaleY(1); }
}
/* Focus clavier : même retour visuel que le survol, sans dépendre du pointeur */
.motion-fill:focus-visible::before { opacity: var(--motion-fill-opacity, 0.14); }
.motion-bar:focus-within::before { transform: scaleY(1); }
.motion-lift:focus-within::after { opacity: 1; } /* clavier : l'ombre seule apparaît, la carte ne se déplace pas */
:focus-visible { transition: none; }  /* l'anneau n'est jamais animé */

/* ───── 4. Défilement doux ───── */
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }

/* ───── 5. Réduction de mouvement : fondus ≤ 200 ms, aucune translation ───── */
@media (prefers-reduced-motion: reduce) {
  [data-seq] { animation-duration: 200ms; animation-delay: 0ms; animation-timing-function: linear; }
  [data-seq='rise'], [data-seq='line'], [data-seq='fade'] { animation-name: seq-fade; }
  [data-seq='word'] { animation-name: seq-fade-word; }
  [data-seq='settle'], [data-seq='draw'] { animation: none; }
  .motion-press:active { transform: none; }
  .motion-lift:hover { transform: none; }
  .motion-arrow, a:hover .motion-arrow, button:hover .motion-arrow { transform: none; transition: none; }
  .motion-underline::after, .motion-bar::before { transition: none; }
  .ambient { animation: none; }
}

/* ───── 6. Impression et exportation : état final forcé ───── */
@media print {
  [data-seq] { animation: none !important; }
  [data-reveal-state] { opacity: 1 !important; transform: none !important; clip-path: none !important; filter: none !important; }
}

Règles associées :

  • Aucun will-change statique dans les feuilles de style hors du sélecteur [data-parallax-scene='on'] [data-parallax-layer] { will-change: transform; } ajouté à ce fichier (§8.7).
  • Les classes .motion-* sont appliquées par les composants (Button, Card, liens de navigation) définis en §5 / §6 ; ces composants ne redéfinissent jamais leurs propres transition.
  • Dimensions des couches de parallaxe : classe .parallax-fill { position: absolute; inset-inline: 0; inset-block-start: calc(var(--parallax-extend, 0px) * -1); height: calc(100% + var(--parallax-extend, 0px)); } (voir §8.4.6).

8.4 Primitives et hooks #

Tous les exemples ci-dessous sont normatifs : l'agent exécutant peut les recopier tels quels (imports de chemins @/… = alias src/). La mise en forme visuelle (couleurs, typographie) vient de §5.

8.4.1 Hooks de base #

// src/hooks/use-isomorphic-layout-effect.ts
import { useEffect, useLayoutEffect } from 'react';
export const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
// src/hooks/use-hydrated.ts
import { useEffect, useState } from 'react';
/** false au rendu serveur et à la première passe client ; true ensuite (après la première commit). */
export function useHydrated(): boolean {
  const [hydrated, setHydrated] = useState(false);
  useEffect(() => setHydrated(true), []);
  return hydrated;
}
// src/hooks/use-media-query.ts
import { useSyncExternalStore } from 'react';
/** Sûr pour le SSR : `serverValue` au rendu serveur et pendant l'hydratation, valeur réelle ensuite. */
export function useMediaQuery(query: string, serverValue = false): boolean {
  return useSyncExternalStore(
    (onChange) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', onChange);
      return () => mql.removeEventListener('change', onChange);
    },
    () => window.matchMedia(query).matches,
    () => serverValue,
  );
}

8.4.2 Profil de mouvement #

// src/lib/motion/profile.ts
export type MotionProfile = 'full' | 'lite' | 'reduced';

export interface ProfileInputs {
  reduced: boolean;
  desktopFine: boolean;
  saveData: boolean;
  lowMemory: boolean;
}

export function resolveProfile(i: ProfileInputs): MotionProfile {
  if (i.reduced) return 'reduced';
  if (!i.desktopFine || i.saveData || i.lowMemory) return 'lite';
  return 'full';
}
// src/hooks/use-reduced-motion-safe.ts
import { MEDIA } from '@/lib/motion/tokens';
import { useMediaQuery } from './use-media-query';
/** Toujours un booléen (jamais null), identique serveur/première passe client (false). */
export const useReducedMotionSafe = (): boolean => useMediaQuery(MEDIA.reduced, false);
// src/hooks/use-is-desktop-pointer-fine.ts
import { MEDIA } from '@/lib/motion/tokens';
import { useMediaQuery } from './use-media-query';
/** true seulement si largeur ≥ 1024 px ET pointeur principal fin (souris/trackpad). */
export const useIsDesktopPointerFine = (): boolean => useMediaQuery(MEDIA.desktopFine, false);
// src/hooks/use-save-data.ts
import { useSyncExternalStore } from 'react';

type NetworkInformation = EventTarget & { saveData?: boolean };
const conn = (): NetworkInformation | undefined =>
  (navigator as Navigator & { connection?: NetworkInformation }).connection;

/** true si l'utilisateur a activé l'économiseur de données (Save-Data). Faux si l'API est absente. */
export function useSaveData(): boolean {
  return useSyncExternalStore(
    (onChange) => {
      const c = conn();
      c?.addEventListener('change', onChange);
      return () => c?.removeEventListener('change', onChange);
    },
    () => conn()?.saveData === true,
    () => false,
  );
}
// src/hooks/use-motion-profile.ts
import { useSyncExternalStore } from 'react';
import { resolveProfile, type MotionProfile } from '@/lib/motion/profile';
import { useIsDesktopPointerFine } from './use-is-desktop-pointer-fine';
import { useReducedMotionSafe } from './use-reduced-motion-safe';
import { useSaveData } from './use-save-data';

const lowMemorySubscribe = () => () => {};
const lowMemorySnapshot = () =>
  ((navigator as Navigator & { deviceMemory?: number }).deviceMemory ?? 8) <= 2;

export function useMotionProfile(): MotionProfile {
  const reduced = useReducedMotionSafe();
  const desktopFine = useIsDesktopPointerFine();
  const saveData = useSaveData();
  const lowMemory = useSyncExternalStore(lowMemorySubscribe, lowMemorySnapshot, () => false);
  return resolveProfile({ reduced, desktopFine, saveData, lowMemory });
}

Valeurs au rendu serveur : reduced = false, desktopFine = false → profil lite. Aucun composant ne produit de balisage dépendant du profil (R-SSR-7).

8.4.3 Armement des révélations : useInViewOnce et useRevealPhase #

// src/hooks/use-in-view-once.ts
import { useEffect, useState, type RefObject } from 'react';
import { VIEWPORT } from '@/lib/motion/tokens';

export interface InViewOnceOptions { rootMargin?: string; enabled?: boolean }

/** Devient true (et le reste) dès que l'élément entre dans le viewport. Sans IntersectionObserver : true. */
export function useInViewOnce(
  ref: RefObject<Element | null>,
  { rootMargin = VIEWPORT.rootMargin, enabled = true }: InViewOnceOptions = {},
): boolean {
  const [seen, setSeen] = useState(false);
  useEffect(() => {
    if (!enabled || seen) return;
    const el = ref.current;
    if (!el) return;
    if (typeof IntersectionObserver === 'undefined') { setSeen(true); return; }
    const io = new IntersectionObserver((entries) => {
      if (entries.some((e) => e.isIntersecting)) { setSeen(true); io.disconnect(); }
    }, { rootMargin });
    io.observe(el);
    return () => io.disconnect();
  }, [ref, rootMargin, enabled, seen]);
  return seen;
}
// src/hooks/use-reveal-phase.ts
import { useState, type RefObject } from 'react';
import { VIEWPORT } from '@/lib/motion/tokens';
import { useHydrated } from './use-hydrated';
import { useIsoLayoutEffect } from './use-isomorphic-layout-effect';

/**
 * ssr     : rendu serveur + première passe client. AUCUN style de mouvement.
 * static  : élément déjà visible ou déjà dépassé à l'hydratation → jamais masqué, jamais animé.
 * hidden  : élément entièrement sous le viewport → état de départ appliqué (durée 0).
 * visible : l'élément est entré dans le viewport → animation d'entrée jouée une seule fois.
 */
export type RevealPhase = 'ssr' | 'static' | 'hidden' | 'visible';

/** Délai de secours : un élément visible à l'écran mais jamais déclenché est révélé après ce délai. */
export const REVEAL_FALLBACK_MS = 1200;

const atDocumentEnd = () =>
  window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;

export function useRevealPhase(
  ref: RefObject<Element | null>,
  { rootMargin = VIEWPORT.rootMargin }: { rootMargin?: string } = {},
): RevealPhase {
  const hydrated = useHydrated();
  const [phase, setPhase] = useState<RevealPhase>('ssr');

  useIsoLayoutEffect(() => {
    if (!hydrated) return;
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    // Déjà dépassé (au-dessus) ou au moins partiellement visible : on ne masque jamais.
    if (rect.bottom <= 0 || rect.top < window.innerHeight || typeof IntersectionObserver === 'undefined') {
      setPhase('static');
      return;
    }
    setPhase('hidden');

    let timer: ReturnType<typeof setTimeout> | undefined;
    const reveal = () => { setPhase('visible'); main.disconnect(); safety.disconnect(); };

    // Déclencheur normal : l'élément franchit la ligne -10 % du bas du viewport.
    const main = new IntersectionObserver((entries) => {
      if (entries.some((e) => e.isIntersecting)) reveal();
    }, { rootMargin });

    // Filet de fin de document : un élément situé dans les 10 % inférieurs du viewport alors que la page
    // est défilée au maximum (pied de page, mention de TVA, téléphone en paysage) n'atteint jamais -10 %.
    // Dès qu'il est visible à l'écran (marge 0), on le révèle immédiatement en fin de document, sinon
    // après REVEAL_FALLBACK_MS. Aucun écouteur de scroll : IntersectionObserver uniquement.
    const safety = new IntersectionObserver((entries) => {
      const visible = entries.some((e) => e.isIntersecting);
      if (!visible) { clearTimeout(timer); timer = undefined; return; }
      if (atDocumentEnd()) { reveal(); return; }
      timer ??= setTimeout(reveal, REVEAL_FALLBACK_MS);
    }, { rootMargin: '0px' });

    main.observe(el);
    safety.observe(el);
    return () => { main.disconnect(); safety.disconnect(); clearTimeout(timer); };
  }, [hydrated, ref, rootMargin]);

  return phase;
}

/** Correspondance phase → label de variant (undefined = ne rien appliquer). */
export const phaseToLabel = (p: RevealPhase): 'hidden' | 'visible' | undefined =>
  p === 'hidden' ? 'hidden' : p === 'visible' ? 'visible' : undefined;

Pourquoi useHydrated() : le profil (useMotionProfile) ne reflète le client qu'après la première commit d'hydratation. L'armement attend cette seconde passe pour choisir les bons variants (distance full ou lite).

Fin de document : aucun élément armé ne peut rester masqué définitivement. Un élément qui n'atteint jamais la ligne de déclenchement parce qu'il se trouve dans les 10 % inférieurs du viewport (page défilée au maximum, écran bas en paysage) est révélé dès qu'il est visible et que le document est défilé au maximum (à 2 px près), ou au plus tard REVEAL_FALLBACK_MS (1,2 s) après être devenu visible à l'écran. Le bandeau inférieur du footer (mentions, copyright) n'est de toute façon jamais enveloppé dans Reveal (§8.5.10).

8.4.4 <Reveal> et <StaggerGroup> #

// src/components/motion/reveal.tsx
'use client';

import { m } from 'motion/react';
import { useRef, type ReactNode, type Ref } from 'react';
import { REVEAL_SETS, type RevealVariantName } from '@/lib/motion/variants';
import { phaseToLabel, useRevealPhase } from '@/hooks/use-reveal-phase';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import { VIEWPORT } from '@/lib/motion/tokens';

const TAGS = { div: m.div, p: m.p, li: m.li, span: m.span, section: m.section, figure: m.figure, article: m.article, h2: m.h2, h3: m.h3, ul: m.ul } as const;

export interface RevealProps {
  as?: keyof typeof TAGS;               // défaut 'div'
  variant?: RevealVariantName;          // défaut 'fadeUp'
  delay?: number;                       // secondes, ≥ 0, défaut 0
  rootMargin?: string;                  // défaut VIEWPORT.rootMargin
  className?: string;
  id?: string;
  children: ReactNode;
}

export function Reveal({
  as = 'div', variant = 'fadeUp', delay = 0, rootMargin = VIEWPORT.rootMargin, className, id, children,
}: RevealProps) {
  const ref = useRef<HTMLElement>(null);
  const profile = useMotionProfile();
  const phase = useRevealPhase(ref, { rootMargin });
  const Tag = TAGS[as] as typeof m.div;
  return (
    <Tag
      ref={ref as Ref<HTMLDivElement>}
      id={id}
      className={className}
      variants={REVEAL_SETS[variant][profile]}
      animate={phaseToLabel(phase)}
      custom={{ delay }}
      data-reveal-state={phase}
    >
      {children}
    </Tag>
  );
}
// src/components/motion/stagger-group.tsx
'use client';

import { m } from 'motion/react';
import { Children, useMemo, useRef, type ReactNode, type Ref } from 'react';
import { phaseToLabel, useRevealPhase } from '@/hooks/use-reveal-phase';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import { clampStagger } from '@/lib/motion/stagger';
import { STAGGER, VIEWPORT, LITE } from '@/lib/motion/tokens';
import { REVEAL_SETS, staggerContainer, ZERO_DELAY, type RevealVariantName } from '@/lib/motion/variants';

export interface StaggerGroupProps {
  as?: 'div' | 'ul' | 'ol' | 'section';
  step?: number;                // secondes ; défaut STAGGER.cards
  delay?: number;               // secondes avant le premier enfant
  rootMargin?: string;
  className?: string;
  children: ReactNode;          // chaque enfant direct doit être un <StaggerItem>
}

const TAGS = { div: m.div, ul: m.ul, ol: m.ol, section: m.section } as const;

export function StaggerGroup({ as = 'div', step = STAGGER.cards, delay = 0, rootMargin = VIEWPORT.rootMargin, className, children }: StaggerGroupProps) {
  const ref = useRef<HTMLElement>(null);
  const profile = useMotionProfile();
  const phase = useRevealPhase(ref, { rootMargin });
  const count = Children.count(children);
  const effective = profile === 'reduced' ? 0 : clampStagger(step, count) * (profile === 'lite' ? LITE.timeScale : 1);
  const variants = useMemo(() => staggerContainer(effective), [effective]);
  const Tag = TAGS[as] as typeof m.div;
  return (
    <Tag ref={ref as Ref<HTMLDivElement>} className={className} variants={variants}
         animate={phaseToLabel(phase)} custom={{ delay }} data-reveal-state={phase}>
      {children}
    </Tag>
  );
}

export interface StaggerItemProps {
  as?: 'div' | 'li' | 'p' | 'article';
  variant?: RevealVariantName;  // défaut 'fadeUpLg' pour des cartes ; 'fadeUp' sinon (à passer explicitement)
  className?: string;
  children: ReactNode;
}
const ITEM_TAGS = { div: m.div, li: m.li, p: m.p, article: m.article } as const;

export function StaggerItem({ as = 'div', variant = 'fadeUp', className, children }: StaggerItemProps) {
  const profile = useMotionProfile();
  const Tag = ITEM_TAGS[as] as typeof m.div;
  // `custom` explicite : le délai du parent est déjà intégré via delayChildren.
  return <Tag className={className} variants={REVEAL_SETS[variant][profile]} custom={ZERO_DELAY}>{children}</Tag>;
}
// src/lib/motion/stagger.ts
import { STAGGER } from './tokens';
/** Plafonne le décalage cumulé d'un groupe : (count − 1) × step ≤ STAGGER.maxTotal. */
export function clampStagger(step: number, count: number): number {
  if (count <= 1) return 0;
  return Math.min(step, STAGGER.maxTotal / (count - 1));
}

Règles :

  • Reveal ne se compose pas dans un StaggerGroup (utiliser StaggerItem) ; un Reveal ne contient pas d'autre Reveal.
  • delay n'est utilisé que pour enchaîner un élément après un titre voisin (valeurs autorisées : 0 à 0,4 s). Pas de délai arbitraire pour « faire joli ».
  • En profil reduced, StaggerGroup n'introduit aucun décalage : tous les enfants apparaissent ensemble en fondu ≤ 0,2 s.

8.4.5 <RevealLines> et <SeqLines> : titres ligne par ligne #

Principe : un titre est fourni déjà découpé en lignes (tableau de chaînes issu du contenu, §7 / §9.3). Le découpage n'est jamais calculé à l'exécution (aucune mesure de texte, aucune bibliothèque de split). Chaque ligne est enveloppée par un masque overflow: hidden ; le <span> interne glisse de translateY(105%) à 0.

Balisage commun (identique pour RevealLines et SeqLines) :

<h2 class="…">
  <span class="line-mask"><span class="line-mask__inner">Une entreprise</span></span>{" "}
  <span class="line-mask"><span class="line-mask__inner">qui respire <em>avec</em> vous.</span></span>
</h2>
/* src/styles/motion.css (suite) — masques de lignes */
.line-mask {
  display: block;
  overflow: hidden;
  /* Évite de couper accents, jambages et empattements italiques sans changer la hauteur de ligne */
  padding-block: 0.14em;
  margin-block: -0.14em;
  padding-inline: 0.06em;
  margin-inline: -0.06em;
}
.line-mask__inner { display: block; }

Règles de contenu :

  • Un espace ({" "}) sépare les lignes dans le DOM : le lecteur d'écran lit une phrase continue, sans pause artificielle ; aucune aria-label n'est ajoutée au titre.
  • Un titre comporte de 1 à 4 lignes. Au-delà, le composant lève une erreur en développement et ne plante pas en production (rend les lignes sans animation).
  • Si une ligne dépasse la largeur disponible (petit écran), elle revient à la ligne à l'intérieur de son masque et s'anime comme un bloc : comportement accepté ; la lisibilité reste intacte.
  • Mise en relief : la syntaxe *mot* dans une ligne produit <em> (italique Fraunces) via parseInlineEmphasis ; pas d'imbrication ; astérisques non appariés rendus littéralement.
// src/lib/motion/inline-emphasis.ts
export type LineSegment = { text: string; em: boolean };
export function parseInlineEmphasis(line: string): LineSegment[] {
  const parts = line.split(/(\*[^*]+\*)/g).filter((p) => p.length > 0);
  return parts.map((p) =>
    /^\*[^*]+\*$/.test(p) ? { text: p.slice(1, -1), em: true } : { text: p, em: false },
  );
}
// src/components/motion/reveal-lines.tsx
'use client';

import { m } from 'motion/react';
import { Fragment, useMemo, useRef, type Ref } from 'react';
import { LINE_SET, staggerContainer } from '@/lib/motion/variants';
import { phaseToLabel, useRevealPhase } from '@/hooks/use-reveal-phase';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import { parseInlineEmphasis } from '@/lib/motion/inline-emphasis';
import { LITE, STAGGER } from '@/lib/motion/tokens';

const TAGS = { h1: m.h1, h2: m.h2, h3: m.h3, p: m.p } as const;

export interface RevealLinesProps {
  lines: readonly string[];            // 1 à 4 lignes, découpage éditorial fourni par le contenu
  as?: keyof typeof TAGS;              // défaut 'h2'
  delay?: number;                      // secondes
  className?: string;
  id?: string;
}

export function RevealLines({ lines, as = 'h2', delay = 0, className, id }: RevealLinesProps) {
  const ref = useRef<HTMLElement>(null);
  const profile = useMotionProfile();
  const phase = useRevealPhase(ref);
  const step = profile === 'reduced' ? 0 : STAGGER.lines * (profile === 'lite' ? LITE.timeScale : 1);
  const container = useMemo(() => staggerContainer(step), [step]);
  const Tag = TAGS[as] as typeof m.h2;
  const inner = LINE_SET[profile];

  return (
    <Tag ref={ref as Ref<HTMLHeadingElement>} id={id} className={className} variants={container}
         animate={phaseToLabel(phase)} custom={{ delay }} data-reveal-state={phase}>
      {lines.map((line, i) => (
        <Fragment key={i}>
          <span className="line-mask">
            <m.span className="line-mask__inner" variants={inner} custom={{ delay: 0 }}>
              {parseInlineEmphasis(line).map((s, j) => (s.em ? <em key={j}>{s.text}</em> : s.text))}
            </m.span>
          </span>
          {i < lines.length - 1 ? ' ' : null}
        </Fragment>
      ))}
    </Tag>
  );
}

Le composant serveur SeqLines rend le même balisage avec data-seq="line" et un délai échelonné issu de HERO_SEQUENCE.line (utilisé uniquement pour le <h1> du hero, §8.5.1) :

// src/components/motion/seq-lines.tsx — composant serveur, aucun hook
import { Fragment } from 'react';
import { seqStyle } from '@/lib/motion/hero-sequence';
import { parseInlineEmphasis } from '@/lib/motion/inline-emphasis';

export function SeqLines({ lines, as: Tag = 'h1', className }: { lines: readonly string[]; as?: 'h1' | 'h2'; className?: string }) {
  return (
    <Tag className={className}>
      {lines.map((line, i) => (
        <Fragment key={i}>
          <span className="line-mask">
            <span className="line-mask__inner" data-seq="line" style={seqStyle('line', i)}>
              {parseInlineEmphasis(line).map((s, j) => (s.em ? <em key={j}>{s.text}</em> : s.text))}
            </span>
          </span>
          {i < lines.length - 1 ? ' ' : null}
        </Fragment>
      ))}
    </Tag>
  );
}

8.4.6 <ParallaxScene> et <ParallaxLayer> #

Une scène crée une seule instance de useScroll (progression 0 → 1 d'une zone cible) partagée par toutes ses couches ; chaque <ParallaxLayer> en dérive son décalage via useTransform. L'arbre React est identique dans les trois profils : la scène et les couches rendent toujours les mêmes éléments (div + m.div) et useScroll est toujours appelé ; seul un « gain » (MotionValue valant 1 en profil full, 0 sinon) module le décalage. Lorsque la parallaxe est désactivée (écran < 1024 px, pointeur grossier, prefers-reduced-motion, économiseur de données), toutes les MotionValue de décalage restent à 0 : aucun style de translation n'est produit. Il n'y a aucune parallaxe pointeur : la scène ne lit ni la souris ni le pointeur.

// src/components/motion/parallax-layer.tsx
'use client';

import { m, motionValue, useMotionValue, useScroll, useTransform, type MotionValue, type UseScrollOptions } from 'motion/react';
import { createContext, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { PARALLAX_LAYERS, type ParallaxLayerName } from '@/lib/motion/tokens';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import { cn } from '@/lib/cn';

interface SceneValue { progress: MotionValue<number>; gain: MotionValue<number> }
// Valeur par défaut d'une couche rendue hors scène : progression et gain à 0 (aucun décalage).
const SceneContext = createContext<SceneValue>({ progress: motionValue(0), gain: motionValue(0) });

export interface ParallaxSceneProps {
  /** Fenêtre de scroll mesurée. Hero : ['start start', 'end start'] ; footer : ['start end', 'end end']. */
  offset?: UseScrollOptions['offset'];
  className?: string;
  children: ReactNode;
}

export function ParallaxScene({ offset = ['start start', 'end start'], className, children }: ParallaxSceneProps) {
  const ref = useRef<HTMLDivElement>(null);
  const enabled = useMotionProfile() === 'full';
  const { scrollYProgress } = useScroll({ target: ref, offset });  // appelé dans tous les profils
  const gain = useMotionValue(0);
  const [near, setNear] = useState(true);

  // 1 en profil full, 0 sinon : aucun remontage, seules les valeurs changent.
  useEffect(() => { gain.set(enabled ? 1 : 0); }, [enabled, gain]);

  // will-change uniquement tant que la scène est proche du viewport (voir §8.7.2).
  useEffect(() => {
    const el = ref.current;
    if (!enabled || !el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => setNear(Boolean(e?.isIntersecting)), { rootMargin: '200px 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, [enabled]);

  const value = useMemo(() => ({ progress: scrollYProgress, gain }), [scrollYProgress, gain]);

  return (
    <SceneContext.Provider value={value}>
      <div ref={ref} className={className} data-parallax-scene={!enabled ? 'off' : near ? 'on' : 'idle'}>
        {children}
      </div>
    </SceneContext.Provider>
  );
}

export interface ParallaxLayerProps {
  layer: ParallaxLayerName;
  /** Multiplicateur d'amplitude (0–1), défaut 1. Permet d'atténuer une couche sans changer le preset. */
  depth?: number;
  /** Couche qui remplit la scène : étendue vers le haut de son déplacement maximal (y ≥ 0 requis). */
  fill?: boolean;
  className?: string;
  children: ReactNode;
}

export function ParallaxLayer({ layer, depth = 1, fill = false, className, children }: ParallaxLayerProps) {
  const { progress, gain } = useContext(SceneContext);
  const spec = PARALLAX_LAYERS[layer];
  const y = useTransform([progress, gain], ([p, g]: number[]) => g * depth * (spec.y[0] + (spec.y[1] - spec.y[0]) * p));
  const extend = fill ? Math.max(spec.y[0], spec.y[1], 0) * depth : 0;
  const fillStyle = fill ? ({ '--parallax-extend': `${extend}px` } as CSSProperties) : undefined;
  return (
    <m.div className={cn(fill && 'parallax-fill', className)} data-parallax-layer={layer} style={{ ...fillStyle, y }}>
      {children}
    </m.div>
  );
}

Contrat de dimensionnement :

  • Une couche fill est une boîte position: absolute pleine largeur, dont le haut est relevé de --parallax-extend (classe .parallax-fill, §8.3.5) afin qu'aucun bord de l'image ne soit visible quand elle descend. L'extension vaut le déplacement maximal de la couche : 120 px pour heroFar, 64 px pour heroMid, 24 px pour footerFar, 12 px pour footerMid, 0 px pour heroNear. L'image à l'intérieur est ancrée en bas (object-position: bottom). Le parent (hero, footer) porte overflow: clip et isolation: isolate.
  • La géométrie est identique dans les trois profils (même enveloppe .parallax-fill), seul le déplacement varie : aucun décalage de mise en page ne peut survenir quand le profil change.
  • Une couche non fill (texte, portrait, mot CLARTÉ) n'est pas étendue.
  • will-change: transform n'est posé que sur [data-parallax-scene='on'] [data-parallax-layer] (CSS ci-dessous) ; ajouter à motion.css :
[data-parallax-scene='on'] [data-parallax-layer] { will-change: transform; }
  • Aucun remontage au changement de profil. Au moment de l'hydratation, le profil passe de lite à full : le gain passe de 0 à 1, sans que le moindre nœud DOM (image préchargée du hero, portrait, éléments data-seq) soit démonté ni recréé. Si la page est rechargée en milieu de hero, les couches « sautent » une seule fois vers leur position de scroll courante ; ce saut est accepté (aucun layout shift car seul transform change). Un test d'identité de nœud (§8.8.1) vérifie que les éléments [data-lcp] et [data-seq] sont les mêmes nœuds avant et après le basculement de matchMedia.

8.4.7 <AmbientDrift> : ambiances courtes en arrière-plan #

Enveloppe décorative (aria-hidden="true") autour d'un halo, de la brume ou du reflet de rivière. Chaque ambiance joue un seul cycle de 5 s au plus (animation-iteration-count: 1), dont l'état de départ et l'état d'arrivée sont identiques (état de repos symétrique, WCAG 2.2.2 : aucun mouvement ne dure plus de 5 s ni ne reste dans un état dérivé). Le cycle est rejoué à chaque entrée dans le viewport : à la sortie, l'état repasse à idle (aucune animation), à l'entrée à running (l'animation redémarre). Elle passe à paused lorsque l'onglet est masqué. Elle est absente en profils lite (écran < 1024 px ou pointeur grossier) et reduced.

// src/hooks/use-ambient-state.ts
import { useEffect, useState, type RefObject } from 'react';

export type AmbientState = 'off' | 'idle' | 'running' | 'paused';

export function useAmbientState(ref: RefObject<Element | null>, enabled: boolean): AmbientState {
  const [inView, setInView] = useState(false);
  const [tabVisible, setTabVisible] = useState(true);

  useEffect(() => {
    if (!enabled) return;
    const el = ref.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => setInView(Boolean(e?.isIntersecting)), { rootMargin: '0px' });
    io.observe(el);
    return () => io.disconnect();
  }, [ref, enabled]);

  useEffect(() => {
    if (!enabled) return;
    const sync = () => setTabVisible(document.visibilityState === 'visible');
    sync();
    document.addEventListener('visibilitychange', sync);
    return () => document.removeEventListener('visibilitychange', sync);
  }, [enabled]);

  if (!enabled) return 'off';
  if (!inView) return 'idle';
  return tabVisible ? 'running' : 'paused';
}
// src/components/motion/ambient-drift.tsx
'use client';

import { useRef, type ReactNode } from 'react';
import { useAmbientState } from '@/hooks/use-ambient-state';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import { cn } from '@/lib/cn';

export type AmbientVariant = 'halo-a' | 'halo-b' | 'mist' | 'shimmer';

export function AmbientDrift({ variant, className, children }: { variant: AmbientVariant; className?: string; children?: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const enabled = useMotionProfile() === 'full';
  const state = useAmbientState(ref, enabled);
  return (
    <div
      ref={ref}
      aria-hidden="true"
      className={cn('ambient', `ambient-${variant}`, className)}
      data-ambient-state={state}
    >
      {children}
    </div>
  );
}

Le même élément div est rendu dans tous les profils (aucun remontage) ; hors des états running et paused, la feuille de style n'applique aucune animation (§8.3.5). Règles : au plus 3 ambiances simultanément en cours d'exécution dans le viewport (hero : halo-a, halo-b, mist ; footer : shimmer et mist, jamais en même temps que celles du hero car hors champ). Une ambiance n'anime jamais un élément qui porte du texte.

8.4.8 <CountUp> : chiffres de confiance #

Besoin : les chiffres de confiance du hero (300+, 50+, valeurs de site.ts, §9.3) montent de 0 à leur valeur une seule fois, sans jamais faire entendre de valeur intermédiaire aux lecteurs d'écran, sans décalage de mise en page et sans clignotement.

Mécanisme :

  1. Rendu serveur : la valeur finale (300+) est dans le HTML, dans deux éléments tous deux aria-hidden="true" : un fantôme (visibility: hidden, simple réserveur de largeur) et une couche visible qui porte la valeur affichée. Attribut data-count-final="300+" sur l'enveloppe. Le texte lu par les technologies d'assistance n'est pas produit ici : le composant TrustFigure (§5.12.7) l'ajoute dans un VisuallyHidden (« Plus de 300 personnes accompagnées »). Les lecteurs d'écran lisent donc toujours la valeur finale, jamais une valeur intermédiaire ; aucune région aria-live n'est utilisée.
  2. Après hydratation, selon le déclencheur :
    • trigger="cssAnimationStart" (hero) : si l'ancêtre [data-seq] n'a pas encore commencé son animation (apparition retardée), la couche visible passe à 0 (invisible à ce moment car l'ancêtre est à opacity: 0), puis le décompte démarre à l'événement animationstart de l'ancêtre. Si l'animation a déjà commencé ou est terminée (hydratation tardive), la valeur finale reste affichée et aucun décompte n'est joué.
    • trigger="inView" (usage générique) : si l'élément est déjà visible à l'hydratation, valeur finale conservée ; sinon 0 puis décompte à l'entrée dans le viewport.
  3. Profils : reduced et saveData → valeur finale, jamais de décompte. lite → 700 ms. full → 900 ms (COUNT_UP).
  4. Courbe : easeOutExpo (1 − 2^(−10t)), proche de easeClarte, sans dépendance Motion supplémentaire.
  5. Mise en forme : Intl.NumberFormat('fr-FR') (espace insécable fine pour les milliers), font-variant-numeric: tabular-nums (classe tabular-nums), suffixe concaténé (+).
// src/lib/motion/tween.ts
export interface TweenOptions {
  from: number; to: number; durationMs: number;
  onUpdate: (rounded: number) => void; onDone?: () => void;
}
/** Interpolation sur requestAnimationFrame ; n'émet que lorsque l'entier arrondi change ; retourne la fonction d'annulation. */
export function runTween(o: TweenOptions): () => void {
  let raf = 0; let t0 = -1; let last = Number.NaN; let cancelled = false;
  const ease = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
  const frame = (now: number) => {
    if (cancelled) return;
    if (t0 < 0) t0 = now;
    const p = Math.min(1, (now - t0) / o.durationMs);
    const v = Math.round(o.from + (o.to - o.from) * ease(p));
    if (v !== last) { last = v; o.onUpdate(v); }
    if (p < 1) raf = requestAnimationFrame(frame); else o.onDone?.();
  };
  raf = requestAnimationFrame(frame);
  return () => { cancelled = true; cancelAnimationFrame(raf); };
}
// src/components/motion/count-up.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { COUNT_UP } from '@/lib/motion/tokens';
import { useHydrated } from '@/hooks/use-hydrated';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import { useSaveData } from '@/hooks/use-save-data';
import { runTween } from '@/lib/motion/tween';
import { cn } from '@/lib/cn';

const nf = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 });
export const formatCount = (n: number): string => nf.format(Math.round(n));

export interface CountUpProps {
  value: number;                         // entier ≥ 0
  suffix?: string;                       // ex. '+'
  trigger?: 'inView' | 'cssAnimationStart';
  className?: string;
}

export function CountUp({ value, suffix = '', trigger = 'inView', className }: CountUpProps) {
  const finalText = `${formatCount(value)}${suffix}`;
  const rootRef = useRef<HTMLSpanElement>(null);
  const hydrated = useHydrated();
  const profile = useMotionProfile();
  const saveData = useSaveData();
  const [shown, setShown] = useState(finalText); // serveur + première passe client = valeur finale

  useEffect(() => {
    if (!hydrated || profile === 'reduced' || saveData) return;
    const root = rootRef.current;
    if (!root) return;
    const durationMs = profile === 'full' ? COUNT_UP.durationMs.full : COUNT_UP.durationMs.lite;
    let cancel = () => {};
    const play = () => {
      cancel = runTween({
        from: 0, to: value, durationMs,
        onUpdate: (v) => setShown(`${formatCount(v)}${suffix}`),
        onDone: () => setShown(finalText),
      });
    };

    if (trigger === 'cssAnimationStart') {
      const host = root.closest<HTMLElement>('[data-seq]');
      const anim = host?.getAnimations()[0];
      const delay = Number(anim?.effect?.getComputedTiming().delay ?? 0);
      const waiting = Boolean(host && anim && Number(anim.currentTime ?? 0) < delay);
      if (!host || !waiting) return;               // déjà visible : valeur finale conservée
      setShown(`0${suffix}`);
      host.addEventListener('animationstart', play, { once: true });
      return () => { host.removeEventListener('animationstart', play); cancel(); setShown(finalText); };
    }

    const rect = root.getBoundingClientRect();
    if (rect.top < window.innerHeight && rect.bottom > 0) return; // déjà visible : valeur finale conservée
    setShown(`0${suffix}`);
    const io = new IntersectionObserver((entries) => {
      if (entries.some((e) => e.isIntersecting)) { io.disconnect(); play(); }
    }, { rootMargin: '0px 0px -10% 0px' });
    io.observe(root);
    return () => { io.disconnect(); cancel(); setShown(finalText); };
  }, [hydrated, profile, saveData, trigger, value, suffix, finalText]);

  return (
    <span ref={rootRef} className={cn('inline-grid tabular-nums', className)} data-count-final={finalText}>
      <span className="invisible col-start-1 row-start-1" aria-hidden="true" data-count-ghost>{finalText}</span>
      <span className="col-start-1 row-start-1 select-none" aria-hidden="true" data-count-visible>{shown}</span>
    </span>
  );
}

Nettoyage : si l'effet est nettoyé en cours de décompte (démontage, changement de profil vers reduced, double montage de StrictMode), la fonction de nettoyage annule le requestAnimationFrame et repose la valeur finale (setShown(finalText)). L'état ne reste jamais bloqué sur une valeur intermédiaire (AC-8.4-17).

8.4.9 useSectionObserver (navigation) #

// src/hooks/use-section-observer.ts
import { useEffect, useState } from 'react';
import { SECTION_OBSERVER } from '@/lib/motion/tokens';

export interface SectionObserverOptions {
  rootMargin?: string;
  /** Ids qui, lorsqu'ils sont dans la bande de lecture, ramènent l'état actif à null (ex. le hero). */
  resetIds?: readonly string[];
}

/**
 * Retourne l'id de la section dont la zone chevauche la « ligne de lecture » (10 % de hauteur, à 40–50 % du haut),
 * ou null si le hero est en lecture. Si aucune section ne chevauche la bande (zones de transition, footer),
 * l'état précédent est conservé. Les ids absents du DOM (ex. #temoignages non rendu) sont ignorés.
 */
export function useSectionObserver(
  ids: readonly string[],
  { rootMargin = SECTION_OBSERVER.rootMargin, resetIds = ['accueil'] }: SectionObserverOptions = {},
): string | null {
  const [active, setActive] = useState<string | null>(null);
  const key = `${resetIds.join('|')}#${ids.join('|')}`;

  useEffect(() => {
    const observed = [...resetIds, ...ids];
    const elements = observed
      .map((id) => document.getElementById(id))
      .filter((el): el is HTMLElement => el !== null);
    if (elements.length === 0 || typeof IntersectionObserver === 'undefined') return;

    const inBand = new Set<string>();
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) inBand.add(e.target.id); else inBand.delete(e.target.id);
      }
      const winner = observed.filter((id) => inBand.has(id)).at(-1); // dernier dans l'ordre du document
      if (winner === undefined) return;
      setActive(resetIds.includes(winner) ? null : winner);
    }, { rootMargin, threshold: 0 });

    elements.forEach((el) => io.observe(el));
    return () => io.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps -- `key` encode ids et resetIds
  }, [key, rootMargin]);

  return active;
}

L'id retourné alimente aria-current="location" et la classe .motion-underline du lien correspondant (§6.1). Le hook ne modifie jamais l'URL (history) au scroll.

L'en-tête n'est jamais masqué. Il reste visible et collant à toute position de défilement : le mouvement n'implémente ni masquage au défilement vers le bas, ni détection de direction de défilement, ni constantes de seuil dédiées. Ses états visuels top, scrolled et solid (seuils de 24 px et de 8 px, fond et flou) sont définis en §6.1. La seule animation de la barre est celle du panneau du menu mobile (§8.5.2).

8.4.10 <StableSwap> et <PanelStack> : changements de panneau sans décalage de mise en page #

Deux mécanismes, un par famille de cas :

  • PanelStack (boussole de méthode §6.4 et slider de témoignages §6.8) : tous les panneaux restent montés dans une seule cellule de grille. Aucune copie invisible, aucun démontage : les ancres aria-controls, le texte indexable et la hauteur du plus grand panneau sont garantis par construction.
  • StableSwap (résultat de la boussole d'orientation §6.6 uniquement) : un seul résultat est monté à la fois ; la hauteur du plus grand est réservée par des calques dimensionneurs.
PanelStack #

Le panneau actif passe à opacity: 1 (et y: 0) ; chaque panneau inactif passe à opacity: 0 (avec une translation de départ de 8 px pour la boussole de méthode, 12 px pour le slider) puis reçoit visibility: hidden en fin de fondu et l'attribut inert. Un panneau inactif n'est donc ni visible, ni focalisable, ni lu par les technologies d'assistance, tout en restant dans le DOM.

// src/components/motion/panel-stack.tsx
'use client';

import { m, type Variants } from 'motion/react';
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { panelStackVariants } from '@/lib/motion/variants';
import { useHydrated } from '@/hooks/use-hydrated';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import { cn } from '@/lib/cn';

interface StackValue { activeId: string; variants: Variants; hydrated: boolean }
const StackContext = createContext<StackValue | null>(null);

export interface PanelStackProps {
  activeId: string;
  /** 'xs' = 8 px (boussole de méthode) ; 'sm' = 12 px (slider de témoignages). */
  distance: 'xs' | 'sm';
  className?: string;
  children: ReactNode;                  // des <PanelStackItem>
}

export function PanelStack({ activeId, distance, className, children }: PanelStackProps) {
  const profile = useMotionProfile();
  const hydrated = useHydrated();
  const variants = useMemo(() => panelStackVariants(distance, profile), [distance, profile]);
  const value = useMemo(() => ({ activeId, variants, hydrated }), [activeId, variants, hydrated]);
  return (
    <StackContext.Provider value={value}>
      <div className={cn('panel-stack', className)} data-hydrated={hydrated ? '' : undefined}>
        {children}
      </div>
    </StackContext.Provider>
  );
}

export interface PanelStackItemProps {
  itemId: string;
  className?: string;
  /** Attributs ARIA fournis par la section (rôle `tabpanel` ou `group`, id, étiquettes) : voir §6.4 et §6.8. */
  role?: 'tabpanel' | 'group';
  id?: string;
  'aria-labelledby'?: string;
  'aria-label'?: string;
  'aria-roledescription'?: string;
  children: ReactNode;
}

export function PanelStackItem({ itemId, className, children, ...aria }: PanelStackItemProps) {
  const stack = useContext(StackContext);
  if (!stack) throw new Error('PanelStackItem doit être rendu dans un PanelStack.');
  const active = itemId === stack.activeId;
  return (
    <m.div
      {...aria}
      className={cn('panel-stack__item', className)}
      data-panel-state={active ? 'active' : 'inactive'}
      inert={!active}
      variants={stack.variants}
      initial={false}
      // Avant hydratation : aucun style de mouvement ; l'état est porté par le CSS (data-panel-state).
      animate={stack.hydrated ? (active ? 'active' : 'inactive') : undefined}
    >
      {children}
    </m.div>
  );
}
/* src/styles/motion.css (suite) — pile de panneaux */
.panel-stack { display: grid; }
.panel-stack > .panel-stack__item { grid-area: 1 / 1; }
/* Avant l'hydratation, seul le panneau actif est visible (aucun clignotement) */
.panel-stack:not([data-hydrated]) > .panel-stack__item[data-panel-state='inactive'] { opacity: 0; visibility: hidden; }
/* Sans JavaScript : tous les panneaux sont visibles, empilés en liste verticale (aucun contenu perdu) */
@media (scripting: none) {
  .panel-stack { display: block; }
  .panel-stack:not([data-hydrated]) > .panel-stack__item[data-panel-state] { opacity: 1; visibility: visible; }
}

Règles : initial={false} (aucun mouvement à l'hydratation) ; le panneau actif au chargement (étape ecouter, ou la première citation) est directement visible dans le HTML serveur ; les contrôles de navigation (onglets, flèches, points) sont masqués sans JavaScript par les règles de la section (§6.4, §6.8).

StableSwap (orientation uniquement) #

Problème : changer le résultat d'orientation avec des contenus de hauteurs différentes provoquerait un saut de page. Solution : une grille d'une seule cellule contient (a) un calque dimensionneur par résultat possible (copie invisible, inert, aria-hidden, sans interaction) qui impose la hauteur du plus grand, et (b) le résultat vivant animé par AnimatePresence en mode popLayout.

// src/components/motion/stable-swap.tsx
'use client';

import { AnimatePresence, m } from 'motion/react';
import { useMemo, type ReactNode } from 'react';
import { swapVariants } from '@/lib/motion/variants';
import { useMotionProfile } from '@/hooks/use-motion-profile';
import type { DistanceToken } from '@/lib/motion/tokens';
import { cn } from '@/lib/cn';

export interface StableSwapProps<K extends string> {
  /** Toutes les clés possibles, dans l'ordre d'affichage. */
  order: readonly K[];
  activeKey: K;
  distance?: DistanceToken;               // défaut 'xs' (8 px), axe vertical
  /** `sizer: true` pour le calque invisible : ne doit produire ni id, ni name, ni élément focalisable. */
  render: (key: K, ctx: { sizer: boolean }) => ReactNode;
  className?: string;
}

export function StableSwap<K extends string>({
  order, activeKey, distance = 'xs', render, className,
}: StableSwapProps<K>) {
  const profile = useMotionProfile();
  const variants = useMemo(() => swapVariants(distance, profile), [distance, profile]);
  return (
    <div className={cn('relative grid [&>*]:col-start-1 [&>*]:row-start-1', className)}>
      {order.map((k) => (
        <div key={`sizer-${k}`} aria-hidden="true" inert className="pointer-events-none invisible select-none" data-nosnippet>
          {render(k, { sizer: true })}
        </div>
      ))}
      <AnimatePresence initial={false} mode="popLayout">
        <m.div key={activeKey} variants={variants} initial="enter" animate="center" exit="exit">
          {render(activeKey, { sizer: false })}
        </m.div>
      </AnimatePresence>
    </div>
  );
}

Contraintes pour le résultat d'orientation (§6.6) :

  • Le contenu rendu avec sizer: true est textuel : pas d'id, pas de name, pas de contrôle de formulaire. Les liens/boutons présents dans le résultat vivant sont absents ou neutralisés (inert s'en charge) dans le calque.
  • Le conteneur de StableSwap n'a pas de hauteur fixée en CSS : elle résulte du plus grand calque. La hauteur est donc toujours correcte à toute largeur d'écran, sans mesure JavaScript.
  • Les copies invisibles portent data-nosnippet et sont masquées aux technologies d'assistance ; elles ne dégradent ni l'accessibilité ni le référencement (texte masqué, contenu déjà présent dans le résultat vivant).
  • Au premier rendu, initial={false} : aucun mouvement ; l'état idle est immédiatement visible dans le HTML serveur.

8.4.11 Aiguille de la boussole et géométrie #

La géométrie de la boussole (§6.4) place les quatre étapes sur un cadran, dans le sens horaire, aux angles suivants (0° = haut) :

// src/lib/motion/geometry.ts
export const METHOD_STEP_ANGLES = { ecouter: 0, explorer: 90, clarifier: 180, decider: 270 } as const;
export type MethodStepId = keyof typeof METHOD_STEP_ANGLES;

export const normalizeAngle = (a: number): number => ((a % 360) + 360) % 360;

/** Plus court chemin de `from` (angle cumulé) vers `to` ; à 180°, sens horaire. */
export function shortestDelta(from: number, to: number): number {
  const d = normalizeAngle(to - from);
  return d > 180 ? d - 360 : d;
}
// src/components/motion/compass-needle.tsx
'use client';

import { m } from 'motion/react';
import { useState } from 'react';
import { METHOD_STEP_ANGLES, normalizeAngle, shortestDelta, type MethodStepId } from '@/lib/motion/geometry';
import { SPRING } from '@/lib/motion/tokens';
import { useMotionProfile } from '@/hooks/use-motion-profile';

/** Angle cumulé : l'aiguille ne fait jamais un tour complet inutile. */
function useCumulativeAngle(target: number): number {
  const [angle, setAngle] = useState(target);
  if (normalizeAngle(angle) !== normalizeAngle(target)) setAngle(angle + shortestDelta(angle, target));
  return angle;
}

export function CompassNeedle({ step, children }: { step: MethodStepId; children: React.ReactNode }) {
  const angle = useCumulativeAngle(METHOD_STEP_ANGLES[step]);
  const profile = useMotionProfile();
  return (
    <m.div
      aria-hidden="true"
      className="pointer-events-none absolute inset-0"
      initial={false}
      animate={{ rotate: angle }}
      transition={profile === 'reduced' ? { duration: 0 } : SPRING.needle}
    >
      {children /* le dessin SVG de l'aiguille (§5, §6.4), pointant vers le haut à 0° */}
    </m.div>
  );
}

initial={false} : au chargement ou lors d'un accès direct #methode-decider, l'aiguille est posée à l'angle de l'étape sans animation ; les changements ultérieurs (clic, clavier, hashchange) la font tourner par ressort. L'aiguille est un div HTML qui pivote autour de son centre (transform-origin: 50% 50%), contenant un SVG : on évite ainsi les ambiguïtés de transform-box des SVG.

8.4.12 Helper de séquence du hero (hero-sequence.ts) #

Source unique des délais et durées de la séquence d'entrée du hero (§8.5.1) ; les valeurs sont injectées dans le HTML serveur sous forme de variables CSS, consommées par motion.css (§8.3.5).

// src/lib/motion/hero-sequence.ts — serveur-compatible
import type { CSSProperties } from 'react';

export interface SeqStep { delayMs: number; durationMs: number; staggerMs?: number; distancePx?: number }

export const HERO_SEQUENCE = {
  veil:        { delayMs: 0,    durationMs: 900 },
  settleFar:   { delayMs: 0,    durationMs: 1400 },
  settleMid:   { delayMs: 0,    durationMs: 1400 },
  header:      { delayMs: 300,  durationMs: 500 },
  eyebrow:     { delayMs: 0,    durationMs: 500 },
  word:        { delayMs: 200,  durationMs: 900 },
  portrait:    { delayMs: 250,  durationMs: 900, distancePx: 24 },
  line:        { delayMs: 100,  durationMs: 900, staggerMs: 80 },
  description: { delayMs: 650,  durationMs: 500, distancePx: 16 },
  band:        { delayMs: 650,  durationMs: 500, distancePx: 16 },   // bande cadrée < 1024 px
  cta:         { delayMs: 800,  durationMs: 500, staggerMs: 100, distancePx: 16 },
  trust:       { delayMs: 1000, durationMs: 500, staggerMs: 100, distancePx: 12 },
  railLine:    { delayMs: 700,  durationMs: 900 },
  rail:        { delayMs: 900,  durationMs: 500, staggerMs: 100, distancePx: 12 },
} as const satisfies Record<string, SeqStep>;
export type HeroSeqId = keyof typeof HERO_SEQUENCE;

/** Plafonds de contenu supposés par le calcul de durée totale (testés en §8.8.1). */
export const HERO_MAX = { lines: 4, ctas: 2, trust: 3, rail: 4 } as const;
export const HERO_COUNT_UP_MS = 900;
export const HERO_SEQUENCE_BUDGET_MS = 2200;

export function seqStyle(id: HeroSeqId, index = 0): CSSProperties {
  const s: SeqStep = HERO_SEQUENCE[id];
  const style: Record<string, string> = {
    '--seq-delay': `${s.delayMs + (s.staggerMs ?? 0) * index}ms`,
    '--seq-dur': `${s.durationMs}ms`,
  };
  if (s.distancePx !== undefined) style['--seq-y'] = `${s.distancePx}px`;
  return style as CSSProperties;
}

8.5 Chorégraphie par section #

8.5.0 Lecture des tableaux #

  • Déclencheur : load (séquence CSS à la première peinture), inView (entrée dans le viewport, une seule fois, rootMargin 0px 0px -10% 0px), scroll (valeur liée à la progression du scroll via useScroll), state (changement d'état d'un composant), hover / focus (pointeur fin ou clavier), press (appui).
  • Durée / courbe : tokens de §8.3 ; « slow » = 0,9 s, « base » = 0,5 s, etc.
  • Mobile désigne le profil lite (< 1024 px, pointeur grossier, économiseur de données) ; Reduced le profil reduced. Les profils ne se cumulent pas : reduced l'emporte.
  • Les distances annoncées sont celles du profil full ; lite les divise par 2 et multiplie les durées par 0,7 (§8.6) sauf mention contraire.
  • Chaque révélation au scroll est unique : quitter puis ré-entrer dans le viewport ne la rejoue pas.

8.5.1 Hero « CLARTÉ » (section B, #accueil) #

Câblage (composant serveur ; la composition, la grille, les tailles et les textes relèvent de §6.2 et §7) #

Le hero est un composant serveur. Il contient une seule instance du texte (eyebrow, <h1>, description, CTA, chiffres de confiance, rail). Deux « scènes » exclusives l'entourent, choisies par CSS (hidden/lg:*, jamais par JavaScript) : au-dessus de 1024 px, l'empilement de couches plein cadre ; en dessous, la bande cadrée (aucun texte sur l'image, ordre vertical : eyebrow → titre → description → CTA → bande → chiffres → rail). Les images sont rendues par next/image ; seul le calque lointain hero-far est préchargé (§11.3.3).

// src/components/sections/hero/hero-section.tsx — extrait de câblage des animations uniquement
// Les z-index sont ceux des jetons de §5.7.4 (--z-hero-*) : jamais de valeur numérique brute.
<section id="accueil" aria-labelledby="accueil-titre" tabIndex={-1} className="relative isolate overflow-clip bg-forest-950">
  {/* ≥ 1024 px : empilement de couches (bas → haut : far, mid, scrim, mot, halo, portrait, near, voile, grain) */}
  <ParallaxScene className="absolute inset-0 hidden lg:block">
    <ParallaxLayer layer="heroFar" fill className="z-(--z-hero-far)">
      {/* enveloppe de parallaxe (Motion) > enveloppe de séquence (CSS) > image */}
      <div data-seq="settle" style={seqStyle('settleFar')} className="h-full w-full">
        <HeroFarLayer data-lcp="hero-landscape" /> {/* next/image, fetchPriority="high", object-bottom ; §11.3.3 */}
      </div>
    </ParallaxLayer>
    <ParallaxLayer layer="heroMid" fill className="z-(--z-hero-mid)">
      <div data-seq="settle" style={seqStyle('settleMid')} className="h-full w-full">
        <AmbientDrift variant="mist" className="h-full w-full">
          <Image src="/images/hero/hero-mid.webp" alt="" fill sizes="100vw" quality={75} className="object-cover object-bottom" />
        </AmbientDrift>
      </div>
    </ParallaxLayer>
    <div aria-hidden="true" className="hero-scrim z-(--z-hero-scrim)" />   {/* --scrim-hero-x : §5.4.1 ; statique */}
    <ParallaxLayer layer="heroWord" className="z-(--z-hero-word)">
      <div aria-hidden="true" data-layout="decor" data-seq="word" style={seqStyle('word')} className="hero-word">CLARTÉ</div>
    </ParallaxLayer>
    <div className="z-(--z-hero-halo)"><AmbientDrift variant="halo-a" className="halo-a" /><AmbientDrift variant="halo-b" className="halo-b" /></div>
    <ParallaxLayer layer="heroPortrait" className="z-(--z-hero-portrait)">
      <div data-layout="media" data-hero-box="portrait" data-seq="rise" style={seqStyle('portrait')}>{/* <HeroPortrait/> : next/image hero-portrait */}</div>
    </ParallaxLayer>
    <ParallaxLayer layer="heroNear" fill className="z-(--z-hero-near)">
      <Image src="/images/hero/hero-near.webp" alt="" fill sizes="100vw" quality={75} className="object-cover object-bottom" />
    </ParallaxLayer>
    {/* Voile : fait « apparaître » le paysage sans toucher à l'opacité de l'image LCP */}
    <div aria-hidden="true" data-seq="veil" style={seqStyle('veil')} className="pointer-events-none absolute inset-0 bg-forest-950 z-(--z-hero-veil)" />
    <div aria-hidden="true" className="hero-grain z-(--z-hero-grain)" />    {/* §5.4.7 ; statique */}
  </ParallaxScene>

  {/* Contenu (grille de §6.2.2) : couche --z-hero-content ; l'unique <h1> de la page */}
  <div className="hero-content relative z-(--z-hero-content)">
    <ParallaxLayer layer="heroText"> {/* gain à 0 et y = [0, 0] : n'applique jamais de décalage ni d'opacité */}
      <div className="hero-text">
        <p data-layout="text" data-hero-box="eyebrow" data-seq="fade" style={seqStyle('eyebrow')}>{eyebrow}</p>
        <SeqLines as="h1" id="accueil-titre" lines={headlineLines} className="font-display" /> {/* data-hero-box="headline" */}
        <p data-layout="text" data-hero-box="description" data-seq="rise" style={seqStyle('description')}>{description}</p>
        <div data-layout="control" data-hero-box="ctas">
          <Cta data-seq="rise" style={seqStyle('cta', 0)} /> <Cta data-seq="rise" style={seqStyle('cta', 1)} />
        </div>

        {/* < 1024 px : bande cadrée (rayon 20, overflow clip) — hero-mobile + CLARTÉ + portrait, sans texte */}
        <div data-layout="media" data-hero-box="band" data-seq="rise" style={seqStyle('band')} className="hero-band lg:hidden">
          <Image src="/images/hero/hero-mobile.webp" alt="" fill sizes="100vw" quality={75} data-lcp="hero-band" className="object-cover" />
          <div aria-hidden="true" data-seq="word" style={seqStyle('word')} className="hero-word-band">CLARTÉ</div>
          {/* <HeroPortrait/> : portrait à 92 % de la hauteur de la bande, aligné en bas */}
        </div>

        <ul data-layout="text" data-hero-box="trust">
          {trustFigures.map((f, i) => (
            <li key={f.id} data-seq="rise" style={seqStyle('trust', i)}>
              <CountUp value={f.value} suffix={f.suffix} trigger="cssAnimationStart" /> <span>{f.label}</span>
            </li>
          ))}
        </ul>
      </div>
    </ParallaxLayer>

    {/* Rail de méthode : colonne verticale dans la gouttière droite ≥ 1280 px, rangée horizontale en dessous */}
    <nav data-layout="control" data-hero-box="rail" aria-label="Les quatre temps de la méthode">
      <span aria-hidden="true" data-seq="draw" style={seqStyle('railLine')} className="rail-line" />
      {steps.map((s, i) => <a key={s.id} href={`#methode-${s.id}`} data-seq="rise" style={seqStyle('rail', i)}>{s.label}</a>)}
    </nav>
  </div>
  <div aria-hidden="true" className="hero-edge z-(--z-hero-edge)" />   {/* transition vers la section suivante : statique */}
</section>

Contraintes de câblage :

  1. Ordre de superposition (bas → haut), jetons de §5.7.4 : paysage lointain (--z-hero-far), brume (--z-hero-mid), voile de lisibilité (--z-hero-scrim), mot CLARTÉ (--z-hero-word), halos (--z-hero-halo), portrait (--z-hero-portrait), crête proche (--z-hero-near), voile d'entrée (--z-hero-veil), grain (--z-hero-grain), contenu (--z-hero-content), transition (--z-hero-edge). Le voile d'entrée recouvre donc le paysage, le mot, les halos, le portrait et la crête, mais jamais le texte, les boutons ni le rail ; le voile de lisibilité et le grain sont statiques.
  2. Règle P7 : chaque enveloppe ParallaxLayer (Motion : transform) enveloppe un élément data-seq (CSS : transform, opacity) ; aucun élément ne porte les deux.
  3. Le <h1> n'est jamais enveloppé d'un composant client qui retarderait son rendu serveur ; heroText (gain à 0, y: [0, 0]) enveloppe une div, pas le <h1> lui-même. Le texte ne bouge ni ne se fond jamais au défilement.
  4. Les attributs data-layout (famille : text, media, control, decor) servent aux tests de non-chevauchement globaux (§4.5.5). data-hero-box en est l'alias propre au hero (headline, description, ctas, trust, portrait, band, rail) pour les tests de §8.8.4 ; les deux sont présents en production, sans effet visuel.
  5. Aucune image du hero n'est animée en opacity ; les images sous 1024 px et au-dessus de 1024 px ne sont jamais toutes deux téléchargées : l'empilement ≥ 1024 px et la bande < 1024 px sont masqués par CSS (hidden / lg:hidden) et leurs images, hors hero-far, sont chargées paresseusement.
Séquence de chargement (déclencheur load, CSS, valeurs de HERO_SEQUENCE) #
# Élément seqStyle Propriétés De → À Durée / courbe Délai Fin Reduced Mobile (lite)
1 Voile d'entrée (≥ 1024 px) veil opacity 1 → 0 (puis visibility: hidden) 900 ms (slow) / easeSoft 0 900 fondu 200 ms non applicable (pas de voile)
2 « Respiration » paysage lointain et brume (≥ 1024 px) settleFar, settleMid transform scale(1.06) → 1 1 400 ms (hero) / easeSoft 0 1 400 aucune aucune
3 En-tête (barre de navigation) header opacity 0 → 1 500 ms (base) / easeSoft 300 800 fondu 200 ms délai ×0,7
4 Eyebrow eyebrow opacity 0 → 1 500 ms (base) / easeSoft 0 500 fondu 200 ms durée ×0,7
5 Lignes du titre <h1> line (i = 0…3) transform translateY(105%) → 0 900 ms (slow) / easeClarte 100 + 80·i 1 000 à 1 240 fondu 200 ms par ligne, sans décalage ×0,7
6 Mot CLARTÉ (empilement ≥ 1024 px, ou bande < 1024 px) word opacity + transform 0 → 0,14 ; scale(1.03) → 1 900 ms (slow) / easeClarte 200 1 100 fondu 200 ms vers 0,14 fondu seul (pas de scale)
7 Portrait portrait opacity + transform 0 → 1 ; translateY(24px) → 0 900 ms (slow) / easeClarte 250 1 150 fondu 200 ms translateY(12px)
8 Description description idem 0→1 ; translateY(16px) → 0 500 ms (base) / easeClarte 650 1 150 fondu 200 ms 8 px
9 Boutons CTA (2) cta (i = 0, 1) idem translateY(16px) → 0 500 ms (base) / easeClarte 800 / 900 1 300 / 1 400 fondu 200 ms 8 px
10 Bande cadrée (< 1024 px) band idem translateY(16px) → 0 500 ms (base) / easeClarte 650 1 150 fondu 200 ms 8 px
11 Chiffres de confiance (2) trust (i = 0, 1) idem translateY(12px) → 0 500 ms (base) / easeClarte 1 000 / 1 100 1 500 / 1 600 fondu 200 ms 6 px
12 Décompte des chiffres CountUp (cssAnimationStart) texte 0 → valeur 900 ms / easeOutExpo début de l'animation du <li> 1 900 / 2 000 valeur finale directe 700 ms
13 Trait du rail (vertical ≥ 1280 px, horizontal en dessous) railLine transform scaleY(0) → 1 (origine haut) ou scaleX(0) → 1 (origine gauche) 900 ms (slow) / easeClarte 700 1 600 aucune (trait visible) ×0,7
14 Étapes du rail (4) rail (i = 0…3) opacity + transform translateY(12px) → 0 500 ms (base) / easeClarte 900 + 100·i 1 400 à 1 700 fondu 200 ms 6 px

Total : dernière animation (décompte du second chiffre) à 2 000 ms ; avec les plafonds de contenu (4 lignes, 3 chiffres) : 2 100 ms ≤ budget de 2 200 ms (HERO_SEQUENCE_BUDGET_MS, test §8.8.1). En mobile, tous les temps sont multipliés par 0,7 (≈ 1 470 ms au plus).

                0       400     800     1200    1600    2000  2200
veil paysage    ##################
settle far/mid  ############################
en-tête               ##########
eyebrow         ##########
ligne 1           ##################
ligne 2             ##################
ligne 3              ##################
CLARTÉ              ##################
portrait             ##################
description                  ##########
CTA 1                           ##########
CTA 2                             ##########
chiffre 1                           ##########
chiffre 2                             ##########
compteur 1                          ==================
compteur 2                            ==================
rail (trait)                  ##################
rail 1                            ##########
rail 2                              ##########
rail 3                                ##########
rail 4                                  ##########

(1 caractère = 50 ms ; # = animation CSS, = = décompte. Sous 1024 px, la ligne « bande » (650 → 1 150 ms) remplace « veil » et « settle ».)

Ordre de lecture voulu : paysage → titre et CLARTÉ → portrait → description → actions → preuves → méthode. Un CTA est cliquable dès la première peinture (même à opacity: 0, ses cibles tactiles existent) ; aucun élément interactif n'est désactivé pendant la séquence.

États du hero #
État Comportement
Chargement normal Séquence ci-dessus.
JavaScript désactivé ou en échec La séquence CSS se joue ; pas de parallaxe ni de décompte ; chiffres finaux visibles ; page entièrement utilisable.
Hydratation tardive (réseau lent) La séquence a déjà commencé/terminé : CountUp conserve la valeur finale ; aucune ré-animation.
Bascule de profil à l'hydratation (lite → full) Aucun remontage : les mêmes nœuds DOM subsistent ; seuls les gains de parallaxe passent de 0 à 1 (§8.4.6). La séquence CSS n'est pas rejouée.
Rechargement en milieu de hero La séquence rejoue depuis son début (les éléments sont à l'écran) ; la parallaxe saute une fois à la position de scroll courante (§8.4.6).
Retour par l'historique (bfcache) État final affiché, séquence non rejouée.
Image de paysage en échec Fond forest-950 ; séquence inchangée.
Impression État final (§8.3.5, bloc 6).
Parallaxe au scroll (déclencheur scroll, profil full uniquement : ≥ 1024 px, pointeur fin, sans prefers-reduced-motion) #

Scène : hero, offset: ['start start', 'end start'] (progression 0 quand le haut du hero touche le haut du viewport, 1 quand son bas le quitte). Les valeurs sont celles de PARALLAX_LAYERS (§8.3.1).

Couche Preset translateY (début → fin) Autre Reduced Mobile (lite)
Paysage lointain (ciel, pics) heroFar 0 → +120 px — statique statique
Mot CLARTÉ heroWord 0 → +48 px opacité constante (0,14) statique statique
Brume / vallée heroMid 0 → +64 px ambiance mist en plus (un cycle ≤ 5 s) statique statique
Portrait heroPortrait 0 → +24 px — statique statique
Crête proche heroNear 0 ne bouge jamais statique statique
Colonne de texte (h1, description, CTA, chiffres) heroText 0 ne bouge ni ne se fond jamais statique statique
Halos — aucune parallaxe ambiances halo-a, halo-b (un cycle ≤ 5 s) statiques statiques

Règles : les couches lointaines descendent plus que les proches (elles « défilent » plus lentement que la page) ; la brume reste dans les limites de son enveloppe étendue (--parallax-extend = 64 px). Aucune parallaxe pointeur : le paysage et le portrait ne réagissent jamais à la souris ni au toucher ; le seul mouvement lié à l'utilisateur est le défilement. Un élément focalisé au clavier fait défiler la page jusqu'à lui ; le texte, qui ne se déplace pas, reste à pleine opacité en toutes circonstances.

8.5.2 Navigation premium (section A) #

L'en-tête est un élément collant toujours visible (aucun masquage au défilement, aucune translation). Ses états visuels sont top, scrolled (après 24 px de défilement, retour sous 8 px) et solid (pages légales, dès le chargement), définis en §6.1.

Élément Déclencheur Propriétés De → À Durée / courbe Délai Reduced Mobile
Apparition de l'en-tête load (data-seq="fade", seqStyle('header')) opacity 0 → 1 500 ms base / easeSoft 300 ms fondu 200 ms, délai 0 délai et durée ×0,7
Fond de l'en-tête (calque dédié) scroll : état scrolled (> 24 px) opacity du calque 0 → 1 fast / easeSoft 0 instantané identique
Flou d'arrière-plan statique backdrop-filter: blur(12px) appliqué à partir de 768 px (fond forest-950 à 0,85 ; repli à 0,97 sans @supports (backdrop-filter)), non animé ; sous 768 px : fond forest-950 à 0,92, sans flou — — — aucun flou aucun flou
Visibilité de l'en-tête — — toujours visible : aucune animation de masquage — — — —
Lien actif state : useSectionObserver transform du soulignement scaleX(0) → 1, origine gauche (.motion-underline) fast / easeClarte 0 instantané non applicable (liens dans le menu : marqueur statique)
Lien : survol / focus hover, focus-visible soulignement ; anneau de focus statique idem fast 0 soulignement instantané —
CTA « Faire le point » hover / press couche de remplissage opacity ; scale(0.98) à l'appui 0 → 0,14 fast / easeSoft ; appui instant 0 remplissage conservé, pas de scale appui conservé
Bouton menu (burger ↔ fermer) state opacity croisée de 2 icônes + rotate ±45° burger ↔ croix fast / easeClarte 0 fondu seul identique
Menu mobile : panneau plein écran state opacity + transform (MENU_PANEL) opacity 0 → 1 ; translateY(-12px) → 0 entrée 250 ms fast / easeClarte ; sortie en fondu seul (sans translation) 150 ms instant / easeSoft 0 instantané (aucune translation, aucun fondu) identique (profil lite : entrée 175 ms)
Menu mobile : liens state opacity + translateY 0/12 px → 1/0 500 ms / easeClarte 150 + 60·i ms affichés instantanément, sans délai 250 ms, 8 px, 100 + 60·i ms

Règles du menu mobile : le menu est un panneau plein écran (role="dialog", aria-modal="true") doté de sa propre barre (logo et bouton de fermeture) ; il n'y a ni fond séparé (backdrop), ni fermeture au clic hors du panneau, ni glissement latéral. AnimatePresence (mode sync, clé constante "menu-mobile") enveloppe le panneau. Le verrouillage du défilement (html[data-scroll-locked]) et la gestion du focus (piège, Escape, retour au bouton déclencheur) sont exécutés au début de l'ouverture et de la fermeture, jamais à la fin d'une animation (§8.2.7-4) ; le comportement détaillé est en §6.1. Le panneau est inert dès que la sortie commence. Cliquer un lien d'ancre ferme le menu puis défile : la fermeture ne bloque pas la navigation.

Sur les pages légales et système (§6.11), l'en-tête conserve apparition (fondu) et fond, mais aucun autre mouvement de contenu n'existe.

8.5.3 Philosophie — Entreprise vivante (section C, #philosophie) #

Élément Déclencheur Composant / variant Propriétés De → À Durée / courbe Délai Reduced Mobile
Étiquette de section inView Reveal fadeUpSm opacity, y 0/12 px → 1/0 base / easeClarte 0 fondu 200 ms 6 px, ×0,7
Titre éditorial (grandes lignes) inView RevealLines (lignes du contenu §7) y interne 105 % → 0 slow / easeClarte 0 ; décalage 80 ms par ligne fondu 200 ms, simultané ×0,7
Filet champagne sous le titre inView Reveal drawX (origin-left) scaleX 0 → 1 slow / easeClarte 0,5 s fondu ×0,7
Paragraphes d'appui inView StaggerGroup (STAGGER.items) + StaggerItem fadeUp opacity, y 0/16 px → 1/0 base / easeClarte 0,2 s puis 60 ms fondu simultané 8 px
Phrase d'accent (une seule, courte) inView Reveal blurUp opacity, y, filter 0 / 16 px / blur(8px) → 1 / 0 / none slow / easeClarte 0 fondu fadeUp (sans flou)
Halo champagne en arrière-plan ambiant AmbientDrift halo-a (décoratif) transform, opacity un seul cycle de 5 s à chaque entrée dans le viewport, retour à l'état de repos — — aucun aucun

Philosophie reste la section la plus « éditoriale » : au plus 4 lignes animées, 3 paragraphes, 1 phrase d'accent. Le flou animé (blurUp) n'est utilisé nulle part ailleurs en v1.

8.5.4 Méthode — Boussole de clarté (section D, #methode) #

Élément Déclencheur Propriétés De → À Durée / courbe Délai Reduced Mobile
Titre de section inView RevealLines 105 % → 0 slow / easeClarte décalage 80 ms fondu ×0,7
Cadran de la boussole inView Reveal scaleIn scale(0.96)/0 → 1/1 slow / easeClarte 0,1 s fondu fondu seul
Repères et libellés des 4 étapes inView StaggerGroup (STAGGER.rail) + fadeUpSm opacity, y 12 px base 0,3 s puis 100 ms fondu simultané 6 px
Aiguille : balayage d'introduction inView (première fois) rotate −40° autour de l'angle de l'étape initiale → angle de l'étape (ecouter = 0°) SPRING.needle 0,5 s aucun balayage ; aiguille posée aucun balayage
Aiguille : changement d'étape state (clic, toucher, clavier, survol avec intention, hashchange) rotate (angle cumulé, plus court chemin, tie 180° = horaire) angle courant → angle cible SPRING.needle (k 140, c 16) 0 rotation instantanée identique (ressort conservé : un seul élément)
Repère actif state calque de remplissage opacity + scale(1.08) du repère 0 → 1 ; 1 → 1,08 fast / easeSoft 0 opacité seule, instantanée identique
Panneaux d'explication (quatre, tous montés) state PanelStack distance="xs" (8 px) ; le panneau actif s'anime, les trois autres sont visibility: hidden + inert actif : opacity 0 → 1, y 8 px → 0 ; devenant inactif : opacity → 0, y → 8 px puis visibility: hidden entrée base (délai 50 ms) / sortie fast / easeClarte 0 fondu 150 ms, sans translation 4 px, ×0,7

Précisions :

  • Les quatre angles sont METHOD_STEP_ANGLES (§8.4.11) ; ils doivent correspondre aux repères dessinés en §6.4.
  • Les quatre panneaux methode-panel-<id> restent toujours montés dans une seule cellule de grille (PanelStack, §8.4.10) : les onglets (methode-tab-<id>, activation automatique) gardent des cibles aria-controls valides, et sans JavaScript les quatre explications sont lisibles.
  • Quelle que soit l'entrée qui change l'étape active, la même transition s'applique ; l'aiguille suit toujours l'étape active (celle dont le panneau est affiché).
  • Un accès direct #methode-decider (chargement ou hashchange) : à l'ouverture de la page, l'aiguille est posée sur 270° sans balayage (initial={false}) ; en cours de visite, elle tourne par ressort.
  • Les évènements de mesure method_step_open (§9.8) sont émis au changement d'étape, indépendamment de l'animation.
  • Les rails du hero (liens #methode-*) déclenchent l'activation de l'étape et le défilement ; la boussole n'attend pas la fin du défilement pour tourner.

8.5.5 Accompagnements (section E, #accompagnements) #

La section présente un panneau sombre mis en avant puis des lignes éditoriales (pas de grille de cartes, hauteurs indépendantes ; composant OfferItem, variantes panel | row, §6.5).

Élément Déclencheur Composant / variant Propriétés De → À Durée / courbe Délai Reduced Mobile
Titre + introduction inView RevealLines puis Reveal fadeUp voir 8.5.3 — — introduction +0,2 s fondu ×0,7
Panneau mis en avant et lignes éditoriales inView StaggerGroup (STAGGER.cards, 0,1 s) + 4 × StaggerItem fadeUpLg opacity, y 0/24 px → 1/0 base / easeClarte 0 puis 100 ms par offre, dans l'ordre du DOM (ordre de offers.ts) fondu simultané 12 px, pas ×0,7
Panneau « mis en avant » statique — bordure champagne fixe ; aucune boucle, aucun halo animé — — — — —
Survol / focus interne d'une ligne hover (pointeur fin), focus-within .motion-bar (barre verticale de 2 px) transform de la barre scaleY(0) → 1, origine haut fast / easeClarte 0 barre affichée instantanément désactivé sur pointeur grossier (pas de survol collant) ; focus-within conservé
Survol du panneau mis en avant hover (pointeur fin) .motion-lift transform ; ombre en opacity translateY(0) → -4px ; ombre 0 → 1 base / easeClarte 0 ombre en fondu, aucun mouvement désactivé (pointeur grossier) ; au clavier, l'ombre seule apparaît
Détail de l'offre (<details> natif) state (ouverture/fermeture native) — — aucune animation de hauteur (ouverture native instantanée) — — — —
Bouton « Écrire sur WhatsApp » / lien « Écrire via le formulaire » hover, press .motion-fill, .motion-arrow, .motion-press opacity, transform remplissage 0 → 0,14 ; flèche translateX(3px) ; appui scale(0.98) fast ; appui instant 0 remplissage seul appui seul
Tarif — — — aucune animation — — — —
Mention de TVA inView Reveal fade opacity 0 → 1 base 0,2 s après la liste fondu identique

8.5.6 Boussole d'orientation (section F, #orientation) #

Élément Déclencheur Composant Propriétés De → À Durée / courbe Délai Reduced Mobile
Titre + consigne inView RevealLines, Reveal voir 8.5.3 — — — fondu ×0,7
Choix de situation (4 boutons radio natifs, role="radiogroup") inView StaggerGroup (STAGGER.items) + fadeUpSm opacity, y 12 px 0 → 1 base 0,1 s puis 60 ms fondu 6 px
Sélection d'une situation state (:checked natif) .motion-fill (couche d'état sélectionné, pilotée par :checked, sans aria-pressed) couche opacity 0 → 1 (sélectionné) fast / easeSoft 0 instantané identique
Résultat (piste de réflexion, questions, offre suggérée, avertissement) state StableSwap (axe vertical), distance="xs" (8 px), clés idle, decision-bloquee, fatigue, repositionnement, transition opacity, y entrée : y +8 px → 0, opacity 0 → 1 ; sortie : y → −4 px, opacity → 0 entrée base (délai 50 ms) / sortie fast 0 fondu 150 ms 4 px, ×0,7
CTA vers le contact press .motion-fill, .motion-arrow voir 8.5.11 — — — — —

Règles : l'état idle (invitation à choisir une situation) occupe le même emplacement que les résultats, la hauteur est donc stable. Resélectionner la situation déjà active ne rejoue rien. Le focus reste sur le bouton de situation (aucun déplacement de focus lié à l'animation) ; la zone de résultat annonce son contenu via la région vivante définie en §6.6 (portée par le panneau vivant uniquement ; les calques dimensionneurs sont aria-hidden). Le défilement automatique du résultat dans la vue n'a lieu que pour une sélection au pointeur (§6.6), jamais lors d'une navigation aux flèches du clavier, et ne dépend pas de l'animation. Rien n'est stocké ni envoyé : l'animation n'a aucun effet de bord.

8.5.7 À propos de Malka (section G, #a-propos) #

Élément Déclencheur Composant / variant Propriétés De → À Durée / courbe Délai Reduced Mobile
Photographie inView Reveal wipeUp clip-path inset(100% 0 0 0) → inset(0) puis none slow / easeClarte 0 fondu 200 ms montée simple 12 px + fondu (fadeUp)
Titre inView RevealLines 105 % → 0 — slow 0,15 s fondu ×0,7
Paragraphes (présentation, posture, approche) inView StaggerGroup (STAGGER.items) + fadeUp opacity, y 0/16 → 1/0 base 0,3 s puis 60 ms fondu 8 px

La photographie n'est jamais déformée, recadrée dynamiquement ni floutée (§8.1.2). Le rideau wipeUp révèle l'image de bas en haut ; clip-path: none en fin d'animation supprime toute découpe résiduelle.

8.5.8 Témoignages (section H, #temoignages, rendue seulement si ≥ 1 témoignage publiable) #

Élément Déclencheur Composant Propriétés De → À Durée / courbe Délai Reduced Mobile
Titre inView RevealLines 105 % → 0 — slow — fondu ×0,7
Cadre du slider inView Reveal fadeUp — — base 0,2 s fondu 8 px
Changement de témoignage (toutes les citations restent montées) state (boutons précédent/suivant, points, balayage tactile) PanelStack distance="sm" (12 px) ; la citation active s'anime, les autres sont visibility: hidden + inert opacity, y active : opacity 0 → 1, y 12 px → 0 ; devenant inactive : opacity → 0, y → 12 px puis visibility: hidden entrée base (délai 50 ms) / sortie fast / easeClarte 0 fondu 150 ms (sans translation) 6 px, ×0,7
Points de pagination state calque dédié (point visuel de 10 px, zone de clic de 44 px) transform, opacity scale(1) → scale(1.5) ; opacity 0,5 → 1 fast 0 opacité seule identique
Boutons précédent / suivant (48 px) hover, press .motion-fill, .motion-press opacity, transform voir 8.5.11 — — — —

Règles :

  • Pas d'autoplay, pas de défilement automatique ni de boucle de pause : le slider ne change que sur action explicite (WCAG 2.2.2).
  • Bouclage : après la dernière citation, « suivant » revient à la première, et inversement ; les boutons ne sont jamais désactivés. La transition reste la même dans les deux sens (aucun glissement horizontal, aucun mouvement dépendant du sens).
  • Balayage tactile sans API drag : onPointerDown mémorise (x, y) ; onPointerUp calcule dx, dy ; si |dx| ≥ 48 px et |dx| ≥ 1,5·|dy|, passage au suivant (dx < 0) ou au précédent (dx > 0). Le conteneur porte touch-action: pan-y (le défilement vertical reste natif) ; aucun écouteur touchmove/pointermove. Un balayage qui commence sur un bouton ou un lien est ignoré.
  • Un seul témoignage publiable : ni boutons, ni points, ni PanelStack ; la citation est statique et apparaît par Reveal fadeUp.
  • Sans JavaScript, toutes les citations sont listées à la suite et les commandes sont masquées (§6.8, règle scripting: none de §8.4.10).
  • Aucune photographie de client n'est animée (il n'y en a pas, §6.8).

8.5.9 Contact et prise de rendez-vous (section I, #contact) #

Élément Déclencheur Composant Propriétés De → À Durée / courbe Délai Reduced Mobile
Titre inView RevealLines 105 % → 0 — slow — fondu ×0,7
Carte WhatsApp et formulaire inView StaggerGroup (STAGGER.cards) + fadeUpLg opacity, y 24 px → 1/0 base 0,1 s puis 100 ms fondu 12 px
Bouton WhatsApp hover, press .motion-fill, .motion-arrow, .motion-press voir 8.5.11 — — — — —
Champ (focus, saisie) — — — aucune animation (anneau de focus instantané) — — — —
Message d'erreur de champ state (validation) fondu CSS opacity 0 → 1 fast / easeSoft 0 instantané identique
Bouton d'envoi : chargement (le bouton reste focalisable, aria-disabled, champs en lecture seule) state (submitting) spinner CSS transform: rotate 0 → 360° en boucle 1 000 ms, linéaire 0 spinner remplacé par l'icône statique + texte « Envoi en cours… » identique
Bannière d'erreur d'envoi state AnimatePresence wait, clé error.code opacity, y 0/−8 px → 1/0 fast / easeClarte 0 fondu 150 ms identique
Formulaire → panneau de succès state (success) permutation avec hauteur figée (ci-dessous) opacity formulaire 1 → 0 (instant) puis succès 0 → 1 (base) instant / base enchaîné (wait) fondu 150 ms identique
Icône de succès state m.span scale, opacity 0,6/0 → 1/1 SPRING.pop après l'entrée du panneau apparition instantanée identique

Décisions :

  • Pas de secousse (shake) sur erreur de champ ni sur erreur serveur : l'erreur est communiquée par le texte, la couleur d'erreur (§5), l'icône et aria-invalid ; seul un fondu est autorisé.
  • Emplacement d'erreur réservé : chaque champ réserve la hauteur d'une ligne de message (min-height: 1.25rem) ; l'apparition d'une erreur ne déplace aucun élément. Un emplacement de statut (min-height: 3.5rem) est de même réservé sous le bouton d'envoi pour la bannière d'erreur d'envoi.
  • Hauteur figée au succès : avant de basculer vers le panneau de succès, le conteneur mesure sa hauteur une seule fois (getBoundingClientRect().height, action utilisateur) et la fige en min-height ; le panneau de succès est centré dans cet espace. Aucune hauteur n'est animée ; la page ne saute pas. La hauteur figée est relâchée si l'utilisateur déclenche « Envoyer un autre message » (si cette action existe, §6.9).
  • Focus : au passage à success, le focus est déplacé immédiatement vers le titre du panneau de succès (tabIndex={-1}), sans attendre la fin du fondu ; en cas d'échec d'envoi, le récapitulatif d'erreurs reçoit le focus immédiatement (§6.9), sans attendre son fondu d'entrée.
  • Anti-spam / limitation de débit : l'état RATE_LIMITED (§9.4) utilise la même bannière que les autres erreurs ; aucune animation d'attente décomptée n'est ajoutée.
  • Le spinner est le seul mouvement continu autorisé en dehors des ambiances ; il s'arrête dès que la requête se termine (succès ou échec).

Le décor du footer (montagnes et rivière) est entièrement vectoriel (SVG en ligne, §6.10). Scène de parallaxe du footer : offset: ['start end', 'end end'] (progression 0 quand le haut du footer entre par le bas du viewport, 1 quand son bas atteint le bas du viewport). Les couches montent vers leur position de repos (de +Δ à 0) : l'effet est une « émergence » progressive, de faible amplitude (≤ 24 px), uniquement en profil full.

Élément Déclencheur Composant / preset Propriétés De → À Durée / courbe Reduced Mobile
Montagnes lointaines (SVG) scroll ParallaxLayer footerFar (fill) translateY +24 px → 0 lié au scroll statique statique
Montagnes médianes et rivière (SVG) scroll footerMid (fill) translateY +12 px → 0 lié au scroll statique statique
Mot CLARTÉ géant (discret) — aucun preset — statique : couleur ivory-50 à l'alpha 0,08 (WORD_OPACITY.footer), ni parallaxe ni apparition animée — statique statique
Reflet de la rivière lumineuse ambiant AmbientDrift shimmer transform, opacity translateX 0 → +20 % → 0 ; opacity 0,5 → 0,9 → 0,5 un seul cycle de 4 s à chaque entrée dans le viewport aucune animation aucune animation
Brume ambiant AmbientDrift mist transform translateX 0 → +24 px → 0 un seul cycle de 5 s à chaque entrée dans le viewport aucune animation aucune animation
Carte CTA flottante inView Reveal fadeUpLg, délai 0,2 s opacity, y 0 / 24 px → 1 / 0 base / easeClarte fondu 12 px
Carte CTA : survol hover .motion-lift transform ; ombre en opacity translateY(0) → -4px ; ombre 0 → 1 base ombre seule désactivé (pointeur grossier) ; au clavier, l'ombre seule apparaît
Navigation, coordonnées inView StaggerGroup (STAGGER.items) + fadeUpSm opacity, y 0 / 12 px → 1 / 0 base fondu simultané 6 px
Bandeau inférieur (mentions, copyright) — aucun Reveal — aucune animation : toujours visible (dernière ligne du document) — — —

Règles : « flottante » décrit la position (carte position: relative chevauchant la jonction Contact → Footer, voir §6.10), pas un mouvement : la carte ne lévite pas en boucle. Le reflet et la brume jouent un seul cycle par entrée dans le viewport et sont en pause quand l'onglet est caché (§8.4.7). Aucun texte du footer (téléphone, e-mail, localisation) n'est animé autrement que par son apparition.

8.5.11 Boutons, liens et micro-interactions globales #

Implémentées en CSS (classes .motion-*, §8.3.5), sans JavaScript, de façon identique côté serveur et client.

Élément Survol (hover: hover et pointer: fine) Appui (:active, souris et toucher) Focus clavier (:focus-visible) Reduced
Bouton primaire (pastille champagne) .motion-fill : calque opacity 0 → 0,14 en fast .motion-press : scale(0.98) en instant calque de remplissage visible ; anneau statique (2 px + 2 px de décalage : champagne-400 sur fond sombre, turquoise-600 sur fond clair) remplissage conservé ; pas de scale
Bouton secondaire (contour) calque opacity 0 → 0,10 idem idem idem
Icône flèche dans un bouton ou lien .motion-arrow : translateX(3px) en fast — idem au survol aucun déplacement
Lien de texte (corps) soulignement scaleX(0) → 1 (.motion-underline) — souligné en permanence au focus souligné instantané
Lien de navigation voir 8.5.2 — idem —
Panneau mis en avant des offres, carte CTA du footer .motion-lift : translateY(-4px) (8.5.5) — focus-within : ombre seule, sans déplacement ombre seule
Ligne éditoriale d'offre .motion-bar : barre verticale de 2 px (scaleY) — idem au survol barre affichée instantanément
Bouton désactivé aucun survol, aucun appui aucun focusable selon §6.9 —
Cible tactile pas de survol (pointeur grossier) scale(0.98) — pas de scale

Règles : tout survol ou appui qui produit un transform passe uniquement par les classes .motion-* de §8.3.5 — .motion-press (appui : scale(0.98)), .motion-lift (survol : translateY(-4px)), .motion-arrow (flèche : translateX(3px)), .motion-underline et .motion-bar (filets) — toutes neutralisées en profil reduced ; aucun composant ne définit son propre transform de survol ou d'appui ; aucune transition sur color, background-color, border-color, box-shadow, width, height ; aucun survol « collant » sur appareil tactile ; l'anneau de focus n'est jamais animé ni masqué par une animation ; la taille de la cible ne change pas au survol (le scale à l'appui est un retour tactile et n'altère pas la zone d'impact).

8.5.12 Règles communes d'orchestration #

  1. Un titre de section = RevealLines ; un bloc de paragraphes = StaggerGroup + StaggerItem ; un élément isolé = Reveal. Pas d'autre mécanisme d'entrée au scroll.
  2. Profondeur d'orchestration maximale : section → groupe → élément (3 niveaux). Pas de StaggerGroup imbriqué dans un StaggerGroup.
  3. Délai maximal d'un élément après son entrée dans le viewport : 0,5 s (Reveal.delay ≤ 0,5) ; décalage cumulé d'un groupe ≤ 0,6 s (clampStagger).
  4. Durée maximale d'une révélation au scroll : 0,9 s (slow).
  5. Ordre : repère de section (étiquette) → titre → filet/texte → éléments interactifs.
  6. Aucun élément interactif (lien, bouton, champ) n'est rendu inerte ou invisible pendant qu'il s'anime : la cible reste cliquable et focalisable.
  7. Les sections sont indépendantes : l'animation d'une section ne dépend jamais de l'état d'une autre (sauf le rail du hero, simple lien).
  8. La section Témoignages absente (aucun témoignage publiable) n'ajoute ni code actif ni observateur : son lien de navigation n'existe pas, useSectionObserver ignore l'id manquant.

8.6 Simplification mobile et faible puissance #

8.6.1 Déclencheurs #

Signal Source Effet
prefers-reduced-motion: reduce CSS + useReducedMotionSafe Profil reduced (prioritaire)
Largeur < 1024 px (min-width: 1024px) Profil lite
Pointeur principal grossier (pointer: coarse) Profil lite
Économiseur de données navigator.connection.saveData === true Profil lite ; décompte désactivé (valeur finale)
Mémoire appareil ≤ 2 Go navigator.deviceMemory <= 2 (si l'API existe) Profil lite
Aucun des précédents — Profil full

Les signaux sont réévalués en continu (changement de taille de fenêtre, rotation, branchement d'une souris sur une tablette, changement de préférence système). Un changement de profil n'interrompt pas les animations en cours et ne provoque ni rechargement, ni décalage de mise en page, ni remontage d'un arbre React (seules des valeurs changent, §8.4.6).

8.6.2 Matrice des effets par profil #

Effet full lite reduced
Séquence d'entrée du hero (CSS) Complète, ≤ 2,1 s Temps ×0,7, distances ×0,5, sans « respiration », CLARTÉ en fondu Fondus 200 ms, sans translation ni délai
Parallaxe des couches (scroll), hero et footer Oui (≥ 1024 px, pointeur fin, sans réduction de mouvement) ; texte du hero jamais déplacé Non (valeurs de décalage à 0) Non (valeurs à 0)
Parallaxe pointeur (souris) Aucune dans aucun profil Aucune Aucune
Reveal, StaggerGroup, RevealLines Distances d'origine Distances ×0,5, durées ×0,7 Fondu ≤ 0,2 s, sans décalage
Rideau wipeUp (À propos) Oui Montée simple Fondu
Flou animé (blurUp) Oui (1 usage) Non Non
Flou d'arrière-plan de l'en-tête Oui (≥ 768 px, blur(12px)) Oui à partir de 768 px ; sous 768 px : fond opaque à 0,92 Non
Ambiances (halos, brume, reflet) Oui : un seul cycle ≤ 5 s à chaque entrée dans le viewport, en pause onglet masqué Non Non
Chiffres CountUp 900 ms 700 ms (aucun si économiseur de données) Valeur finale directe
Parallaxe de footer lié au scroll Oui Non (couches statiques) Non
En-tête Toujours visible, jamais masqué Toujours visible Toujours visible
Panneau du menu mobile Fondu + translateY(-12px) → 0, fast Idem, durée ×0,7 Instantané
Aiguille de la boussole Ressort Ressort Instantanée
Changement de panneau de la boussole de méthode et du slider (PanelStack, panneaux toujours montés) opacity + y 8 px / 12 px Distances ×0,5, durées ×0,7 Fondu 150 ms
Résultat d'orientation (StableSwap) opacity + y 8 px Distances ×0,5 Fondu 150 ms
Survol (lift −4 px, remplissage, barre) Oui (pointeur fin) Non applicable (pas de survol) Remplissage oui, mouvement non
Appui (scale(0.98)) Oui Oui Non
Défilement doux des ancres Oui Oui Non (saut instantané)
Spinner d'envoi Rotation Rotation Icône statique + texte

8.6.3 Règles complémentaires #

  1. Pas de filter: blur() sur mobile : ni animé, ni statique pour des effets d'ambiance. Les halos sont des dégradés radiaux (radial-gradient) pré-calculés, jamais des éléments flous.
  2. Pas de couche composite supplémentaire en profil lite : sous 1024 px le hero n'utilise qu'une seule image dans sa bande cadrée (hero-mobile, §6.2), l'empilement de couches est masqué ; les ParallaxLayer restent montés mais leurs décalages sont à 0 (aucun will-change).
  3. Aucune boucle infinie dans aucun profil hors spinner fonctionnel (statique en reduced) ; les ambiances sont absentes en lite et reduced et se limitent à un cycle de 5 s en full.
  4. Changement d'orientation / redimensionnement : le passage du profil lite à full (rotation d'une tablette large, redimensionnement desktop) active la parallaxe à partir de la position de scroll courante sans saut visible (progression recalculée par useScroll) et sans remontage (le gain passe de 0 à 1).
  5. Économiseur de données : n'altère pas le chargement des images (réglé par next/image, §11), uniquement le mouvement.
  6. Aucun mouvement n'est conditionné à un test d'agent utilisateur (navigator.userAgent) : uniquement les media queries et les API de capacité listées en 8.6.1.

8.7 Limites de performance #

8.7.1 Budgets chiffrés #

Budget Limite Vérification
Images par seconde Cible 60 fps (frame ≤ 16,7 ms) sur ordinateur portable et téléphone milieu de gamme ; plancher en CI avec CPU ×4 ralenti : p95 des frames ≤ 50 ms, aucune frame > 200 ms §8.8.4 performance.spec.ts + contrôle manuel (§8.8.6)
Éléments animés simultanément ≤ 20 pendant la séquence du hero (CSS, compositeur seul) ; ≤ 12 pour toute révélation au scroll ; ≤ 3 ambiances en cours Comptage document.getAnimations() + data-ambient-state="running"
Couches composites promues ≤ 10 en permanence sur le hero ; somme des surfaces de couches ≤ 6 × la surface du viewport (desktop) Inspecteur de couches (revue manuelle, §8.8.6)
Poids JavaScript du runtime Motion LazyMotion + m + domAnimation + hooks utilisés ≤ 22 Ko gzip, compris dans le budget de §11 Analyse du bundle (@next/bundle-analyzer) en CI
Tâche principale due au mouvement Aucune tâche longue (> 50 ms) attribuable à du code de src/lib/motion ou src/components/motion pendant un défilement complet PerformanceObserver('longtask')
INP Cible globale ≤ 200 ms (§11) ; pour les interactions à mouvement (clic étape de boussole, choix de situation, flèche du slider, ouverture du menu, appui sur un bouton) : peinture de retour ≤ 100 ms et gestionnaire ≤ 50 ms PerformanceObserver('event'), seuil 40 ms
CLS dû au mouvement 0 : aucune entrée layout-shift ne référence un élément [data-reveal-state], [data-seq], [data-parallax-layer], StableSwap, CountUp Playwright, layout-shift avec sources

8.7.2 Politique will-change et promotion de couches #

  1. Aucun will-change global (*, section, .card, etc.) et aucun will-change statique dans les composants.
  2. will-change: transform est posé uniquement sur [data-parallax-scene='on'] [data-parallax-layer] : tant que la scène est à moins de 200 px du viewport. Hors zone, l'attribut passe à idle et la propriété disparaît (le navigateur libère la couche).
  3. Les révélations (Reveal, StaggerGroup, RevealLines) n'ont aucun will-change : Motion/WAAPI promeut l'élément le temps de l'animation et le libère ensuite.
  4. Les animations CSS du hero n'ont pas de will-change : elles sont courtes (≤ 2,1 s) et ne s'exécutent qu'une fois.
  5. Une couche promue ne dépasse pas viewport width × (viewport height + 120 px). Les images de paysage sont dimensionnées à sizes="100vw" (voir §11 pour formats et poids).

8.7.3 Scroll, évènements et mise en page #

  1. Aucun écouteur scroll, wheel, touchmove ni mousewheel écrit à la main. Les effets liés au scroll passent par useScroll (parallaxe : appelé en permanence par chaque scène, ses MotionValue de décalage restent à 0 hors profil full) ou IntersectionObserver (révélations et leur filet de fin de document, section active, ambiances). Règle ESLint (§8.2.1) + test statique (§8.8.2).
  2. Aucun écouteur pointermove : il n'y a pas de parallaxe pointeur.
  3. Aucune animation de layout : pas de layout, layoutId, AnimatePresence avec mode="sync" sur du contenu en flux qui pousserait d'autres éléments (le menu mobile est hors flux, position: fixed).
  4. Aucune lecture de layout synchrone dans une boucle (getBoundingClientRect n'est lu qu'une fois par élément à l'armement, une fois au gel de hauteur du formulaire, et scrollHeight uniquement dans le callback du filet de fin de document).
  5. Les mises à jour d'état React dues au mouvement sont discrètes (§8.2.7-2) ; aucune n'est déclenchée à chaque frame sauf le compteur (≤ 300 changements entiers en 0,9 s, sur un composant minuscule).
  6. Pas de requestAnimationFrame permanent : seul runTween en utilise un, borné à 0,9 s.
  7. Les images animées en transform (paysage, portrait) sont des éléments next/image : aucune animation sur background-image.

8.7.4 Pause hors champ et onglet masqué #

Mécanisme Pause
Ambiances (AmbientDrift) Un seul cycle ≤ 5 s ; état idle (aucune animation) hors champ, paused (animation-play-state: paused) quand document.visibilityState !== 'visible', relance à chaque nouvelle entrée dans le viewport
Parallaxe Progression bornée à [0, 1] : hors zone, aucune écriture de style
Décompte Le requestAnimationFrame s'interrompt avec l'onglet ; à son retour, la valeur finale est atteinte à l'échéance
Séquence CSS Unique, non répétée

8.7.5 Règle d'arbitrage #

Si une animation dépasse un budget de ce chapitre, on réduit l'animation (distance, durée, nombre d'éléments) ou on la supprime ; on n'augmente jamais le budget sans mise à jour de cette section. Une animation qui ne passe pas le profil lite sur un téléphone milieu de gamme n'est pas livrée.

8.8 Tests du mouvement #

Les chemins de dossiers de test suivent l'arborescence de §12 ; la matrice globale des tests est en §15. Ci-dessous : le périmètre propre au mouvement.

8.8.1 Tests unitaires (Vitest + Testing Library) #

Fichier Vérifie
tests/unit/motion/tokens.test.ts Valeurs exactes de DURATION, EASE, STAGGER, SPRING.micro ; ordre strict des durées ; invariants de PARALLAX_LAYERS (valeur absolue de y ≤ 120, seul heroFar = 120 ; heroMid 64, heroWord 48, heroPortrait 24, heroNear 0, heroText 0 ; footerFar 24, footerMid 12 ; couches fill à y ≥ 0 ; aucune clé de pointeur) ; chaque valeur de AMBIENT_PERIOD_S ≤ AMBIENT_MAX_S (5 s) ; WORD_OPACITY = { hero: 0.14, footer: 0.08 } ; l'objet exporté ne contient ni NAV ni POINTER_SPRING
tests/unit/motion/hero-sequence.test.ts Fin de séquence ≤ HERO_SEQUENCE_BUDGET_MS avec HERO_MAX ; première ligne à ≤ 100 ms ; seqStyle produit --seq-delay/--seq-dur/--seq-y
tests/unit/motion/css-parity.test.ts --motion-dur-*, --motion-ease-*, --clarte-opacity* de motion.css égaux aux tokens TypeScript
tests/unit/motion/variants.test.ts Toutes les clés de variants ⊆ {opacity, x, y, scale, rotate, scaleX, scaleY, clipPath, filter, visibility, transition, transitionEnd} ; reduced : aucune clé de transformation, durée ≤ 0,2 s (MENU_PANEL.reduced instantané) ; blur ≤ 8 px ; MENU_PANEL : y de -12 px à 0 et durée d'entrée fast, sortie en fondu seul de durée instant ; `panelStackVariants('xs'
tests/unit/motion/profile.test.ts Table de vérité de resolveProfile (reduced prioritaire ; lite si !desktopFine / saveData / lowMemory)
tests/unit/motion/use-media-query.test.tsx Snapshot serveur = valeur par défaut ; réaction à un évènement change simulé ; désabonnement au démontage
tests/unit/motion/use-reveal-phase.test.tsx ssr au premier rendu ; static si rect visible ou dépassé ; hidden puis visible à l'intersection ; observateur déconnecté ; double montage StrictMode sans fuite ; filet de fin de document : élément armé situé dans les 10 % inférieurs, document défilé au maximum → visible dès que l'observateur à marge 0 le signale ; hors fin de document → visible après REVEAL_FALLBACK_MS (temps simulés), minuteur annulé si l'élément sort du viewport avant l'échéance
tests/unit/motion/use-section-observer.test.tsx Section gagnante = dernière de la bande ; hero (accueil) → null ; aucune intersection → état conservé ; id absent ignoré ; déconnexion au démontage
tests/unit/motion/no-remount.test.tsx Aucun remontage au changement de profil : rendre ParallaxScene + ParallaxLayer + AmbientDrift + une image [data-lcp], basculer matchMedia de lite à full puis à reduced, vérifier que les nœuds DOM [data-lcp], [data-seq], [data-parallax-layer] sont strictement les mêmes (identité), que les MotionValue de décalage valent 0 hors profil full et que transform n'est jamais produit hors full
tests/unit/motion/ambient.test.tsx AmbientDrift rend le même div dans tous les profils ; data-ambient-state : off (hors full), idle (hors viewport), running (visible), paused (onglet masqué) ; la sortie du viewport remet idle (réarmement)
tests/unit/motion/count-up.test.tsx renderToString contient la valeur finale (fantôme + couche visible) ; fantôme et couche visible tous deux aria-hidden (le texte lu vient de TrustFigure, §5.12.7) ; reduced/saveData → jamais de valeur intermédiaire ; nettoyage → valeur finale ; formatage fr-FR
tests/unit/motion/reveal-lines.test.tsx N lignes → N masques ; texte accessible = lignes jointes par un espace ; *mot* → <em> ; erreur si 0 ou > 4 lignes (développement)
tests/unit/motion/stable-swap.test.tsx Un calque dimensionneur par clé, inert + aria-hidden ; une seule clé vivante ; changement de clé sans erreur (résultat d'orientation uniquement)
tests/unit/motion/panel-stack.test.tsx Tous les PanelStackItem restent dans le DOM après chaque changement d'activeId ; le panneau actif n'a pas inert, les autres l'ont et reçoivent visibility: hidden en fin de transition ; rendu serveur : seul le panneau actif est visible (data-panel-state) ; aucune duplication de texte ; changement de profil sans remontage
tests/unit/motion/geometry.test.ts shortestDelta : 270 → 0 = +90 ; 0 → 270 = −90 ; 0 → 180 = +180 ; cumul d'angle sur 8 changements ne dérive pas
tests/unit/motion/stagger.test.ts, tween.test.ts, inline-emphasis.test.ts clampStagger (plafond 0,6 s) ; runTween émet des entiers croissants, termine à to, annulable ; syntaxe *mot* et cas d'astérisques non appariés

Exemple (modèle à suivre) :

// tests/unit/motion/hero-sequence.test.ts
import { describe, expect, it } from 'vitest';
import { HERO_COUNT_UP_MS, HERO_MAX, HERO_SEQUENCE, HERO_SEQUENCE_BUDGET_MS, type SeqStep } from '@/lib/motion/hero-sequence';

const end = (id: keyof typeof HERO_SEQUENCE, n: number) => {
  const s: SeqStep = HERO_SEQUENCE[id];
  return s.delayMs + (s.staggerMs ?? 0) * (n - 1) + s.durationMs;
};

describe('séquence du hero', () => {
  it('se termine avant le budget avec les plafonds de contenu', () => {
    const countUpEnd = HERO_SEQUENCE.trust.delayMs + HERO_SEQUENCE.trust.staggerMs * (HERO_MAX.trust - 1) + HERO_COUNT_UP_MS;
    const ends = [
      end('veil', 1), end('settleFar', 1), end('header', 1), end('line', HERO_MAX.lines), end('word', 1),
      end('portrait', 1), end('description', 1), end('cta', HERO_MAX.ctas), end('trust', HERO_MAX.trust),
      end('railLine', 1), end('rail', HERO_MAX.rail), countUpEnd,
    ];
    expect(Math.max(...ends)).toBeLessThanOrEqual(HERO_SEQUENCE_BUDGET_MS);
  });

  it('démarre la première ligne du titre dans les 100 ms', () => {
    expect(HERO_SEQUENCE.line.delayMs).toBeLessThanOrEqual(100);
  });
});

Exemple de test de hook avec simulation d'IntersectionObserver :

// tests/unit/motion/use-section-observer.test.tsx
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { useSectionObserver } from '@/hooks/use-section-observer';

type Cb = (entries: Array<Partial<IntersectionObserverEntry>>) => void;
let cb: Cb;
beforeEach(() => {
  vi.stubGlobal('IntersectionObserver', class { constructor(c: Cb) { cb = c; } observe() {} disconnect() {} });
  for (const id of ['accueil', 'philosophie', 'methode']) {
    const el = document.createElement('section'); el.id = id; document.body.append(el);
  }
});
afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); });

it('retourne la section dans la bande de lecture puis null dans le hero', () => {
  const { result } = renderHook(() => useSectionObserver(['philosophie', 'methode']));
  const t = (id: string, isIntersecting: boolean) => ({ target: document.getElementById(id)!, isIntersecting });
  act(() => cb([t('philosophie', true)]));
  expect(result.current).toBe('philosophie');
  act(() => cb([t('philosophie', false), t('accueil', true)]));
  expect(result.current).toBeNull();
});

8.8.2 Règles statiques (Vitest, balayage de src/) #

tests/unit/motion/static-rules.test.ts lit les fichiers src/**/*.{ts,tsx,css} et échoue si :

  • un import framer-motion ou un import { motion } depuis motion/react est trouvé ;
  • domMax, layoutId, layout= ou drag (prop Motion) apparaissent ;
  • addEventListener('scroll', addEventListener('wheel', addEventListener('touchmove', onScroll=, onWheel= apparaissent ;
  • will-change apparaît ailleurs que dans src/styles/motion.css (et seulement dans le sélecteur de scène active) ;
  • une clé width, height, top, left, margin, padding, letterSpacing apparaît dans un objet de variant (variants.ts) ou dans un animate={{ … }} ;
  • une règle transition: sur color, background-color, border-color, box-shadow, width ou height est trouvée dans motion.css ;
  • Math.random() ou index est utilisé comme key d'un enfant d'AnimatePresence.

8.8.3 Tests de composants #

  • Reveal, StaggerGroup, RevealLines : rendu serveur (renderToString) sans style ni opacity: 0 ; après montage avec IntersectionObserver simulé, l'état passe ssr → hidden → visible ; le contenu reste dans le DOM à tout moment.
  • Aucun avertissement d'hydratation (console.error espionné) pour une page de test qui rend chaque primitive, avec matchMedia simulé dans les trois profils.

8.8.4 Tests E2E Playwright #

Projets Playwright concernés (définis en §15) : chromium-desktop (1440×900), mobile-chrome (Pixel 7) et mobile-safari (iPhone 14, pour les gestes). Les autres largeurs (320, 390, 768, 834, 1024, 1280, 1920) sont obtenues avec page.setViewportSize à partir de la liste VIEWPORTS de §15. Les fichiers sont sous tests/e2e/motion/.

a) Réduction de mouvement : reduced-motion.spec.ts émule reducedMotion: 'reduce' :

// tests/e2e/motion/reduced-motion.spec.ts
import { expect, test } from '@playwright/test';

test.use({ reducedMotion: 'reduce' });

test('aucun mouvement non essentiel en reduced-motion', async ({ page }) => {
  await page.goto('/');
  await page.evaluate(() => Promise.all(document.getAnimations().map((a) => a.finished.catch(() => null))));

  // 1. Aucune boucle infinie
  const infinite = await page.evaluate(
    () => document.getAnimations().filter((a) => a.effect?.getComputedTiming().iterations === Infinity).length,
  );
  expect(infinite).toBe(0);

  // 2. Parallaxe absente : aucune couche ne se déplace au scroll
  await page.evaluate(() => window.scrollTo(0, 700));
  await page.waitForTimeout(100);
  const transforms = await page.$$eval('[data-parallax-layer]', (els) => els.map((e) => getComputedStyle(e).transform));
  expect(transforms.every((t) => t === 'none')).toBe(true);

  // 3. Profil et en-tête toujours visible
  await expect(page.locator('html')).toHaveAttribute('data-motion-profile', 'reduced');
  const headerY = await page.locator('header').evaluate((e) => getComputedStyle(e).transform);
  expect(headerY).toBe('none');

  // 4. Chiffres finaux immédiats (aucune valeur intermédiaire)
  await expect(page.locator('[data-count-visible]').first()).toHaveText('300+');

  // 5. Contenu visible : aucun bloc révélé n'est resté à opacity 0
  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  await page.waitForTimeout(400);
  const stuck = await page.$$eval('[data-reveal-state="hidden"]', (els) => els.length);
  expect(stuck).toBe(0);
});

b) Chargement et LCP : lcp.spec.ts

// tests/e2e/motion/lcp.spec.ts
import { expect, test } from '@playwright/test';

test('l’élément LCP est un élément [data-lcp] ou le h1, jamais masqué, et n’est pas retardé', async ({ page }) => {
  await page.addInitScript(() => {
    (window as any).__lcp = null;
    new PerformanceObserver((list) => {
      const e = list.getEntries().at(-1) as PerformanceEntry & { element?: Element };
      (window as any).__lcp = {
        time: e.startTime,
        marker: e.element?.getAttribute('data-lcp') ?? null,
        tag: e.element?.tagName ?? null,
        opacity: e.element ? getComputedStyle(e.element).opacity : null,
      };
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  });
  await page.goto('/');
  await page.waitForTimeout(3500);
  const lcp = await page.evaluate(() => (window as any).__lcp);
  const isMarked = ['hero-landscape', 'hero-band'].includes(lcp.marker);
  expect(isMarked || lcp.tag === 'H1').toBe(true);
  expect(Number(lcp.opacity)).toBeGreaterThan(0);
  expect(lcp.time).toBeLessThanOrEqual(2500); // cible de §11 (lab, profil mobile émulé dans le projet mobile)
});

c) Séquence du hero et non-chevauchement : hero-overlap.spec.ts attend la fin des animations finies, puis vérifie, pour chaque largeur de VIEWPORTS (320, 390, 768, 834, 1024, 1280, 1440, 1920), qu'aucune de ces paires de boîtes [data-hero-box] ne se recouvre (boîtes vides ou masquées ignorées) : headline↔portrait, description↔portrait, ctas↔portrait, trust↔portrait, rail↔portrait, headline↔rail, ctas↔trust, headline↔band, ctas↔band, trust↔band. À 1024 px et plus, le bord droit du bloc headline + 24 px est inférieur ou égal au bord gauche de portrait. Il vérifie aussi l'ordre des couches du hero par les jetons de §5.7.4 (--z-hero-far < -mid < -scrim < -word < -halo < -portrait < -near < -veil < -grain < -content < -edge, le mot étant donc derrière le portrait) et qu'aucun élément interactif n'est recouvert par un élément non interactif (elementFromPoint sur le centre de chaque CTA/lien du rail retourne l'élément lui-même ou un de ses descendants).

// extrait de tests/e2e/motion/hero-overlap.spec.ts
const PAIRS: Array<[string, string]> = [
  ['headline', 'portrait'], ['description', 'portrait'], ['ctas', 'portrait'], ['trust', 'portrait'],
  ['rail', 'portrait'], ['headline', 'rail'], ['ctas', 'trust'],
  ['headline', 'band'], ['ctas', 'band'], ['trust', 'band'],
];
const boxOf = (page: Page, name: string) =>
  page.locator(`[data-hero-box="${name}"]`).first().boundingBox();
for (const width of [320, 390, 768, 834, 1024, 1280, 1440, 1920]) {
  test(`hero sans chevauchement à ${width}px`, async ({ page }) => {
    await page.setViewportSize({ width, height: width < 768 ? 800 : 900 });
    await page.goto('/');
    await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect?.getComputedTiming().iterations !== Infinity).map((a) => a.finished)));
    for (const [a, b] of PAIRS) {
      const ba = await boxOf(page, a); const bb = await boxOf(page, b);
      if (!ba || !bb || ba.width === 0 || bb.width === 0) continue;
      const overlap = ba.x < bb.x + bb.width && ba.x + ba.width > bb.x && ba.y < bb.y + bb.height && ba.y + ba.height > bb.y;
      expect(overlap, `${a} recouvre ${b} à ${width}px`).toBe(false);
    }
  });
}

(Le titre h1 porte data-hero-box="headline", le bloc portrait data-hero-box="portrait", la bande cadrée data-hero-box="band", etc. : voir le câblage de §8.5.1 ; data-hero-box est l'alias, propre au hero, de l'attribut générique data-layout de §4.5.5.)

d) Profil lite mobile : mobile-lite.spec.ts (projet mobile-chrome) : data-motion-profile="lite" ; aucune couche [data-parallax-layer] ne change de transform après un défilement de 800 px ; aucun [data-ambient-state="running"] (tous à off) ; aucun élément avec filter calculé ≠ none ; aucune boucle infinie ; le texte du hero reste à opacity: 1 après défilement de 300 px.

e) Interactions animées : interactions.spec.ts

  • Boussole : clic sur chaque étape → aria-selected correct ; les quatre panneaux restent dans le DOM, un seul est visible (visibility) et sans inert ; enchaînement de 6 clics en < 1 s : état final = dernière étape, un seul panneau visible (AC-8.5-14).
  • Clavier : flèches entre étapes ; aiguille à l'angle attendu (lecture de getComputedStyle(needle).transform → matrice de rotation).
  • Hash : chargement de /#methode-decider → étape decider active, aiguille à 270° dès la première frame.
  • Orientation : sélection d'une situation → résultat visible, suggestion d'offre correcte ; hauteur du conteneur constante avant/après (écart ≤ 1 px).
  • Slider (variante de test avec au moins deux témoignages publiables) : bouton suivant → citation suivante ; sur la dernière citation, « suivant » revient à la première (bouclage) et les boutons ne sont jamais désactivés ; toutes les citations restent dans le DOM ; balayage tactile (WebKit/Pixel) ; pas de défilement automatique après 10 s.
  • Formulaire (API simulée) : erreur de champ sans décalage de mise en page (position y du bouton d'envoi identique avant/après l'erreur, tolérance 0) ; succès : hauteur du conteneur inchangée, focus sur le titre du panneau de succès.
  • Menu mobile : ouverture/fermeture, focus piégé, retour du focus au bouton, aucune fermeture au clic hors du panneau ; entrée en fondu + translateY(-12px) → 0 ; en profil reduced, apparition instantanée sans translation.
  • En-tête : jamais masqué — après un défilement descendant de 2 000 px puis une remontée, transform reste none et le rectangle de l'en-tête reste dans le viewport ; état scrolled au-delà de 24 px, retour à top sous 8 px.
  • Fin de document : sur un viewport 844×390 (paysage), après défilement jusqu'au bas de la page, aucun élément [data-reveal-state="hidden"] ne subsiste au bout de 1,5 s.

f) Performance : performance.spec.ts (Chromium, CDP) :

// extrait de tests/e2e/motion/performance.spec.ts
test('défilement complet sans saccade majeure (CPU ×4)', async ({ page, context }) => {
  const cdp = await context.newCDPSession(page);
  await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
  await page.goto('/');
  await page.evaluate(() => {
    (window as any).__frames = [];
    let last = performance.now();
    const tick = (now: number) => { (window as any).__frames.push(now - last); last = now; requestAnimationFrame(tick); };
    requestAnimationFrame(tick);
    (window as any).__long = [];
    new PerformanceObserver((l) => (window as any).__long.push(...l.getEntries().map((e) => e.duration))).observe({ entryTypes: ['longtask'] });
  });
  const height = await page.evaluate(() => document.body.scrollHeight);
  for (let y = 0; y < height; y += 300) { await page.mouse.wheel(0, 300); await page.waitForTimeout(60); }
  const { frames, long } = await page.evaluate(() => ({ frames: (window as any).__frames as number[], long: (window as any).__long as number[] }));
  const sorted = [...frames].sort((a, b) => a - b);
  expect(sorted[Math.floor(sorted.length * 0.95)]).toBeLessThanOrEqual(50);
  expect(Math.max(...frames)).toBeLessThanOrEqual(200);
  expect(long.filter((d) => d > 100)).toHaveLength(0);
});

Le même fichier vérifie : zéro entrée layout-shift attribuée à un élément de mouvement ([data-reveal-state], [data-seq], [data-parallax-layer], [data-count-final]) ; durée max des évènements d'interaction (PerformanceObserver type event, durationThreshold: 40) ≤ 100 ms pour les clics sur boussole, situation, flèche de slider et bouton de menu ; nombre maximum de document.getAnimations() simultanés ≤ 20 pendant les 2,5 premières secondes.

8.8.5 Contrôles visuels des états finaux #

visual-end-states.spec.ts (Chromium) capture, avec reducedMotion: 'reduce' (états finaux sans mouvement) et animations: 'disabled', le hero à 390, 768, 1024, 1440 et 1920 px, puis chaque section à 390 et 1440 px (toHaveScreenshot, seuil maxDiffPixelRatio: 0.01). Les captures de référence sont revues manuellement une fois ; un changement de composition (§6) impose leur mise à jour explicite. Critères de revue visuelle : aucun texte coupé par un masque de ligne, aucun chevauchement entre texte et portrait, CLARTÉ derrière le portrait et sous les textes, mots d'accent non rognés (italiques), pas de bord d'image visible en haut des couches de parallaxe après défilement (captures prises à 0, 400 et 800 px de scroll en profil full).

8.8.6 Revue manuelle avant mise en production #

Sur un ordinateur portable et un téléphone Android milieu de gamme, plus un iPhone : défilement continu du haut en bas à vitesse normale et rapide (aucune saccade perceptible) ; ouverture/fermeture rapide du menu ; clics rapides sur la boussole ; rotation de l'appareil ; activation de « Réduire les animations » du système pendant la visite ; capture de l'inspecteur de couches du hero (≤ 10 couches promues).

8.9 Critères d'acceptation #

Chaque critère est vérifiable automatiquement ou en revue ; la colonne « Vérif. » renvoie à §8.8.

8.9.1 Architecture et rendu (§8.2) #

ID Critère Vérif.
AC-8.2-01 Providers (src/components/layout/providers.tsx) enveloppe l'application avec LazyMotion features={domAnimation} strict et MotionConfig reducedMotion="user" ; aucun autre fournisseur Motion n'existe. 8.8.2
AC-8.2-02 Aucun import de framer-motion, aucun motion.* ni domMax dans src/. ESLint, 8.8.2
AC-8.2-03 Aucun avertissement d'hydratation lié au mouvement en développement comme en production, aux trois profils. 8.8.3
AC-8.2-04 Le HTML serveur de / contient tout le texte des sections et aucun attribut style portant opacity:0 ou une transformation de départ ; les seuls styles en ligne liés au mouvement sont les variables --seq-*, --parallax-extend et la rotation d'état final de l'aiguille de la boussole. Test sur la réponse HTML brute
AC-8.2-05 JavaScript désactivé : la page est complète et lisible, la séquence CSS du hero se joue, les chiffres finaux sont visibles. E2E (javaScriptEnabled: false)
AC-8.2-06 L'élément LCP est un élément [data-lcp] (hero-landscape ≥ 1024 px, hero-band) ou le <h1> ; il n'a jamais opacity: 0 et respecte la cible de §11 (≤ 2,5 s). 8.8.4-b
AC-8.2-07 La séquence du hero n'utilise pas Motion (aucun m.* directement dans hero-section.tsx) ; le <h1> est un composant serveur. Revue + 8.8.2
AC-8.2-08 La CSP de §10 autorise style-src-attr 'unsafe-inline' et aucune autre directive n'est nécessaire à Motion. Revue §10
AC-8.2-09 Aucun hook ne fuit sous StrictMode (observateurs déconnectés au double montage). 8.8.1
AC-8.2-10 Le changement de profil (lite → full → reduced) ne démonte ni ne recrée aucun nœud : les éléments [data-lcp], [data-seq], [data-parallax-layer] et [data-ambient-state] gardent la même identité DOM ; les composants rendent toujours m.* et leurs MotionValue de décalage restent à 0 hors profil full. 8.8.1 (no-remount.test.tsx)

8.9.2 Tokens et variants (§8.3) #

ID Critère Vérif.
AC-8.3-01 DURATION = 0,15 / 0,25 / 0,5 / 0,9 / 1,4 s ; EASE.clarte = [0.22, 1, 0.36, 1] ; EASE.soft = [0.33, 1, 0.68, 1] ; SPRING.micro = {300, 30} ; STAGGER.lines = 0,08, STAGGER.cards = 0,1. 8.8.1
AC-8.3-02 Les variables CSS de motion.css sont égales aux tokens TypeScript. 8.8.1
AC-8.3-03 Les variants n'utilisent que opacity, x, y, scale, rotate, scaleX, scaleY, clipPath, filter et visibility (masquage d'un panneau inactif). 8.8.1
AC-8.3-04 Les presets de parallaxe respectent le plafond de 120 px (seul heroFar) : heroMid 64, heroWord 48, heroPortrait 24, heroNear 0, heroText 0, footerFar 24, footerMid 12 ; les couches fill ont y ≥ 0 ; aucun preset de pointeur n'existe. 8.8.1
AC-8.3-05 La séquence du hero se termine ≤ 2 200 ms avec 4 lignes, 3 chiffres, 4 étapes de rail ; la première ligne démarre ≤ 100 ms. 8.8.1
AC-8.3-06 En profil reduced, tout variant se limite à un fondu ≤ 0,2 s. 8.8.1

8.9.3 Primitives et hooks (§8.4) #

ID Critère Vérif.
AC-8.4-01 useMediaQuery renvoie la valeur serveur au premier rendu et réagit aux changements ; désabonnement au démontage. 8.8.1
AC-8.4-02 resolveProfile : reduced prioritaire ; lite si largeur < 1024 px, pointeur grossier, saveData ou mémoire ≤ 2 Go ; sinon full. 8.8.1
AC-8.4-03 Un élément déjà visible ou dépassé à l'hydratation passe en static et n'est jamais masqué. 8.8.1
AC-8.4-04 Un élément situé sous le viewport passe en hidden puis visible à l'entrée, une seule fois. 8.8.1
AC-8.4-05 clampStagger garantit un décalage cumulé ≤ 0,6 s quel que soit le nombre d'enfants. 8.8.1
AC-8.4-06 RevealLines produit un masque overflow: hidden par ligne fournie ; aucun calcul de découpage à l'exécution ; le nom accessible du titre est la concaténation des lignes séparées par des espaces. 8.8.1
AC-8.4-07 Aucun descendant de masque de ligne n'est rogné (accents, jambages, italiques) aux largeurs 320, 768 et 1440 px. 8.8.5
AC-8.4-08 Hors profil full, les décalages de toutes les ParallaxLayer valent 0 : aucun transform de translation n'est produit au défilement ; l'arbre rendu est identique à celui du profil full. 8.8.4-d, 8.8.1
AC-8.4-09 En profil full, la couche heroFar atteint exactement +120 px à progression 1, heroMid +64, heroWord +48, heroPortrait +24, heroNear et heroText 0 ; aucune couche ne révèle son bord supérieur pendant le défilement du hero. 8.8.4 + 8.8.5
AC-8.4-10 will-change: transform n'est présent que sur les couches d'une scène data-parallax-scene="on". 8.8.4
AC-8.4-11 AmbientDrift : hors profil full, data-ambient-state="off" et aucune animation ; en full, une seule itération de 5 s au plus (iterations = 1, durée ≤ 5 000 ms) jouée à chaque entrée dans le viewport, état de repos identique au départ et à l'arrivée, idle hors champ, paused quand l'onglet est masqué ; aucune animation CSS à itérations infinies hors spinner. 8.8.4, 8.8.1
AC-8.4-12 CountUp : le HTML serveur contient la valeur finale (fantôme + couche visible) ; le fantôme (réserveur de largeur) et la couche visible sont tous deux aria-hidden ; le texte lu est fourni par TrustFigure (§5.12.7). 8.8.1
AC-8.4-13 Un lecteur d'écran n'est jamais exposé à une valeur intermédiaire de CountUp (l'arbre d'accessibilité ne contient que la valeur finale pendant le décompte). 8.8.4 (instantané d'accessibilité)
AC-8.4-14 CountUp ne provoque aucun décalage de mise en page (largeur fixée par le fantôme, tabular-nums). 8.8.4-f
AC-8.4-15 CountUp joue au plus une fois ; après hydratation tardive, aucune ré-animation. 8.8.1
AC-8.4-16 En reduced et avec saveData, CountUp affiche la valeur finale sans décompte. 8.8.1
AC-8.4-17 Le nettoyage d'un CountUp en cours (démontage, changement de profil) annule le requestAnimationFrame et repose la valeur finale. 8.8.1
AC-8.4-18 useSectionObserver renvoie null dans le hero, conserve la section précédente dans les zones sans section, ignore un id absent (#temoignages non rendu). 8.8.1
AC-8.4-19 Aucun élément armé par useRevealPhase ne reste masqué en fin de document : un élément situé dans les 10 % inférieurs du viewport, page défilée au maximum, est révélé aussitôt, sinon au plus tard 1,2 s après être devenu visible à l'écran. 8.8.1, 8.8.4-e
AC-8.4-20 PanelStack (boussole de méthode, slider) : tous les panneaux restent montés, un seul est visible et non inert, les autres sont visibility: hidden + inert ; sans JavaScript tous sont visibles. StableSwap (résultat d'orientation uniquement) : hauteur du conteneur constante quelle que soit la clé active (écart ≤ 1 px), calques dimensionneurs inert, aria-hidden et sans id. 8.8.1, 8.8.4-e
AC-8.4-21 shortestDelta choisit le plus court chemin (270→0 = +90°, 0→270 = −90°, 180° = sens horaire) ; l'aiguille ne fait jamais de tour complet. 8.8.1
AC-8.4-22 L'aiguille est posée à l'angle correct sans animation au chargement ou sur #methode-<étape>. 8.8.4-e

8.9.4 Chorégraphie (§8.5) #

ID Critère Vérif.
AC-8.5-01 Séquence du hero : dernière animation terminée à ≤ 2,2 s (≤ 1,5 s en lite) ; ordre de lecture conforme à §8.5.1. 8.8.4
AC-8.5-02 Pendant la séquence du hero, aucun lien ni bouton n'est désactivé ; le <h1> est dans le DOM dès la première réponse HTML. E2E
AC-8.5-03 Les valeurs de parallaxe du hero à progression 0, 0,5 et 1 correspondent à §8.5.1 (tolérance 2 px) ; le texte du hero (<h1>, description, CTA, chiffres) ne se déplace pas et reste à opacity: 1 à toute progression. 8.8.4
AC-8.5-04 Il n'existe aucune parallaxe pointeur : aucun écouteur pointermove dans src/, aucune couche du hero ne change de transform en réponse à la souris. 8.8.2 + 8.8.4
AC-8.5-05 En-tête : fondu d'apparition 300 ms/500 ms ; fond renforcé au-delà de 24 px (retour sous 8 px) ; jamais masqué : aucune translation à toute position de défilement, dans tous les profils. 8.8.4-e
AC-8.5-06 Menu mobile : panneau plein écran en fondu + translateY(-12px) → 0 (entrée 250 ms ; sortie en fondu seul, 150 ms), liens échelonnés, ni fond séparé ni glissement latéral ; focus déplacé au début de l'ouverture/fermeture ; en reduced, apparition instantanée sans translation. 8.8.4-e
AC-8.5-07 Philosophie : blurUp n'est utilisé qu'une fois dans toute l'application ; aucun flou animé en profil lite. 8.8.2 + 8.8.4-d
AC-8.5-08 Boussole : l'aiguille tourne par SPRING.needle vers l'étape active quelle que soit l'entrée (clic, toucher, clavier, hashchange) ; les quatre panneaux restent montés (PanelStack, opacity + y 8 px) sans saut de hauteur. 8.8.4-e
AC-8.5-09 Offres (panneau mis en avant + lignes éditoriales) : apparition échelonnée 0,1 s dans l'ordre de offers.ts ; lift de −4 px du panneau mis en avant et barre de 2 px des lignes uniquement sur pointeur fin ; les tarifs ne s'animent pas ; le <details> s'ouvre sans animation de hauteur. 8.8.4-e
AC-8.5-10 Orientation : changement de résultat par StableSwap (8 px, axe vertical) ; hauteur constante ; resélectionner la même situation ne rejoue rien ; focus conservé sur le bouton. 8.8.4-e
AC-8.5-11 À propos : rideau clip-path en full, simple fondu/montée en lite, fondu en reduced ; clip-path: none en fin d'animation. 8.8.4
AC-8.5-12 Témoignages : toutes les citations montées, transition opacity + y 12 px (PanelStack) identique dans les deux sens, bouclage (boutons jamais désactivés), pas d'autoplay, balayage tactile ≥ 48 px et ratio 1,5 sans drag ; un seul témoignage → citation statique sans contrôle. 8.8.4-e
AC-8.5-13 Formulaire : aucune secousse ; l'apparition d'une erreur ne déplace aucun élément (écart de position 0) ; le passage au succès conserve la hauteur du conteneur et déplace le focus immédiatement. 8.8.4-e
AC-8.5-14 Six clics en moins d'une seconde sur les étapes de la boussole laissent l'état final cohérent (dernière étape active, un seul panneau visible, aucun résidu). 8.8.4-e
AC-8.5-15 Footer : couches en émergence liée au scroll (footerFar +24 px, footerMid +12 px → 0) en full, statiques sinon ; mot CLARTÉ statique ; reflet et brume limités à un cycle ≤ 5 s par entrée dans le viewport ; la carte CTA ne lévite pas en boucle. 8.8.4
AC-8.5-16 Boutons : survol par calque d'opacity (aucune transition de couleur), appui scale(0.98) (absent en reduced), flèche +3 px (absente en reduced), lift −4 px ; tout transform de survol ou d'appui passe par .motion-press / .motion-lift / .motion-arrow ; aucun survol collant sur tactile. 8.8.2 + 8.8.4
AC-8.5-17 L'anneau de focus apparaît instantanément et n'est jamais animé ni masqué par une animation. 8.8.4
AC-8.5-18 Sans témoignage publiable, aucun code ni observateur lié à la section Témoignages n'est actif et l'id absent est ignoré. 8.8.1
AC-8.5-19 Pages légales et système : aucune révélation au scroll ; seul l'en-tête conserve son apparition. 8.8.4

8.9.5 Profils, performance et tests (§8.6 à §8.8) #

ID Critère Vérif.
AC-8.6-01 Sur 390 px de large : data-motion-profile="lite", aucune couche de parallaxe mobile, aucune ambiance en cours (tous les data-ambient-state à off), aucun filter ≠ none. 8.8.4-d
AC-8.6-02 Avec (pointer: coarse) à 1280 px de large : profil lite (aucune parallaxe). E2E
AC-8.6-03 Avec saveData simulé : profil lite, décompte désactivé. E2E
AC-8.6-04 Un changement de préférence « réduire les animations » en cours de visite bascule vers reduced sans rechargement ni décalage de mise en page. E2E
AC-8.6-05 Aucune condition de mouvement ne repose sur navigator.userAgent. 8.8.2
AC-8.7-01 Au plus 20 animations simultanées pendant la séquence du hero, 12 pour une révélation au scroll, 3 ambiances. 8.8.4-f
AC-8.7-02 Aucun écouteur scroll / wheel / touchmove manuel dans src/. ESLint, 8.8.2
AC-8.7-03 Aucune propriété interdite (§8.1.3) n'est animée ; aucun layout, layoutId, drag. 8.8.2
AC-8.7-04 Défilement complet sous CPU ×4 : p95 des frames ≤ 50 ms, aucune frame > 200 ms, aucune tâche longue > 100 ms. 8.8.4-f
AC-8.7-05 Les interactions à mouvement répondent en ≤ 100 ms (peinture) avec un gestionnaire ≤ 50 ms ; l'INP global respecte §11. 8.8.4-f
AC-8.7-06 Zéro layout-shift imputable à un élément de mouvement. 8.8.4-f
AC-8.7-07 Le runtime Motion pèse ≤ 22 Ko gzip. Analyse de bundle
AC-8.7-08 Aucun will-change global ou statique ; la propriété disparaît des couches hors zone. 8.8.2 + 8.8.4
AC-8.8-01 Les fichiers de test listés en 8.8.1 à 8.8.5 existent, s'exécutent dans pnpm test / pnpm test-e2e et passent. CI
AC-8.8-02 reduced-motion.spec.ts passe : aucune boucle infinie, aucune couche déplacée, en-tête visible, chiffres finaux, aucun bloc resté masqué. 8.8.4-a
AC-8.8-03 hero-overlap.spec.ts passe aux huit largeurs de VIEWPORTS (320 à 1920 px) pour toutes les paires listées et vérifie l'ordre des couches du hero. 8.8.4-c
AC-8.8-04 Les captures d'états finaux sont revues et approuvées avant la mise en production. 8.8.5

9. Architecture logicielle, modèle de contenu et contrats d'API #

Cette section est la référence unique pour : la pile technique et ses lignes de version (§9.1), l'architecture et le découpage serveur/client (§9.2), les schémas de contenu et les gardes de build (§9.3), le contrat de l'API de contact (§9.4), l'e-mail transactionnel (§9.5), la limitation de débit et l'anti-spam (§9.6), la journalisation (§9.7), l'analytique (§9.8) et le point d'extension de réservation (§9.9). Toute autre section renvoie ici au lieu de redéfinir ces éléments.

9.1 Stack technique #

Ces lignes de version sont un plancher connu et fiable, pas un fichier de verrouillage. Au moment du build, installez la version stable courante de chaque dépendance (pnpm add <pkg>@latest), vérifiez que la ligne majeure correspond toujours, et laissez le lockfile enregistrer les versions exactes résolues.

Si la chaîne d'outils de Next.js 16.x ne prend pas en charge TypeScript 7.x au moment du build, utilisez la ligne majeure la plus récente qu'elle prend en charge et consignez ce choix dans DECISIONS.md (§18.7). Aucune autre exception de ce type n'est admise.

Dépendance Ligne majeure Rôle
Node.js (LTS « Krypton ») 24 LTS Environnement d'exécution du serveur et des scripts de build
pnpm (via Corepack) 12.x Gestionnaire de paquets ; packageManager épinglé dans package.json ; lockfile pnpm-lock.yaml commité
Next.js 16.x Framework App Router ; output: 'standalone' ; génération statique de / ; Route Handlers nodejs
React / React DOM 19.x Rendu ; composants serveur par défaut, îlots clients explicites (§9.2)
TypeScript 7.x (voir la règle de repli ci-dessus) Mode strict ; noUncheckedIndexedAccess et exactOptionalPropertyTypes activés (§12.7)
Tailwind CSS 4.x Configuration CSS-first (@theme dans src/styles/globals.css)
@tailwindcss/postcss 4.x (même ligne que Tailwind) Intégration PostCSS
motion 14.x Animations ; imports depuis motion/react ; LazyMotion + m.* + domAnimation (§8)
Zod 4.x Schémas partagés client/serveur : contenu (§9.3), requête de contact (§9.4), environnement (§13)
lucide-react 1.x Icônes (imports nominatifs, jamais l'import global)
sharp 0.35.x Dépendance d'exécution : optimisation d'images par next/image dans la sortie standalone, et lecture des métadonnées d'images par scripts/check-assets.ts
server-only 0.0.x Garde de compilation : erreur si un module serveur est importé côté client
tsx 4.x Exécution des scripts TypeScript (scripts/check-assets.ts, §12.9)
Vitest 5.x Tests unitaires et d'intégration (§15)
@testing-library/react 16.x Tests de composants (§15)
Playwright 1.x Tests de bout en bout (§15)
@axe-core/playwright 4.x Contrôles d'accessibilité automatisés (§15)
@lhci/cli 0.15.x Budgets Lighthouse CI (§11, §15)
ESLint (configuration plate) 10.x Analyse statique ; eslint-config-next suit la ligne majeure de Next.js ; règles de frontières (§9.2)
Prettier 3.x Formatage
prettier-plugin-tailwindcss dernière ligne majeure stable compatible avec Prettier 3.x et Tailwind CSS 4.x Tri des classes Tailwind (§12.8)
typescript-eslint 8.x Analyse TypeScript pour ESLint (configuration plate)
eslint-plugin-jsx-a11y 6.x Règles d'accessibilité statiques (§11)
eslint-plugin-no-only-tests 3.x Interdit .only dans les tests (§15)
eslint-config-prettier 10.x Désactive les règles ESLint qui entrent en conflit avec Prettier
clsx 2.x Composition conditionnelle de classes (src/lib/cn.ts, §12.6)
tailwind-merge 3.x Résolution des conflits de classes Tailwind (src/lib/cn.ts, §12.6)
@next/bundle-analyzer même ligne majeure que Next.js Analyse du bundle en CI (§8, §11)
@playwright/test même ligne majeure que Playwright (1.x) Exécuteur de tests de bout en bout (§15)
@vitejs/plugin-react dernière ligne majeure stable compatible avec Vitest 5.x Transformation JSX dans les tests de composants (§15)
jsdom dernière ligne majeure stable Environnement DOM des tests de composants (§15)
@testing-library/user-event 14.x Simulation d'interactions dans les tests de composants (§15)
@testing-library/jest-dom 6.x Assertions DOM dans les tests de composants (§15)
@vitest/coverage-v8 même ligne majeure que Vitest (5.x) Mesure de couverture (§15)
@types/node 24.x (même ligne que Node.js) Types Node.js
@types/react, @types/react-dom 19.x (même ligne que React) Types React
Actions GitHub (actions/checkout, actions/setup-node, pnpm/action-setup, docker/*, actions/upload-artifact) et lignes des images Docker de base (Node, Caddy) majeure stable courante à la date de rédaction du workflow ou du Dockerfile Intégration continue et conteneurs (§14) ; la syntaxe Dockerfile et les épinglages d'actions relèvent de §14
Caddy (image caddy:2-alpine) 2.x Reverse proxy HTTPS optionnel (profil proxy, §14)
Docker Engine + Docker Compose (plugin v2) version courante du dépôt de la distribution Conteneurisation (§14)
API transactionnelle Brevo v3 (REST) Envoi d'e-mails par fetch natif, sans SDK (§9.5)

Dépendances volontairement exclues (leur ajout exige une décision écrite dans DECISIONS.md, voir §12) : tout SDK Brevo ou Resend, nodemailer, axios, tout ORM ou client de base de données, tout client Redis, framer-motion (le paquet s'appelle motion), toute bibliothèque de formulaire (le formulaire utilise des champs natifs et Zod), tout gestionnaire de consentement (CMP), tout script de suivi publicitaire, tout chargement de polices depuis un CDN au runtime.

Politique de mise à jour. Les versions mineures et correctifs sont acceptés à chaque build via le lockfile. Un changement de ligne majeure d'une dépendance requiert : lecture de ses notes de migration, exécution complète des tests (§15) et note dans DECISIONS.md. pnpm up applique les mises à jour dans la même ligne majeure uniquement.

Scripts d'installation des dépendances. Les scripts d'installation sont interdits par défaut. Liste blanche initiale (pnpm.onlyBuiltDependencies) : sharp, et esbuild si pnpm l'exige ; toute entrée supplémentaire est consignée dans DECISIONS.md.

Critères d'acceptation 9.1

  • AC-9.1-01 — package.json contient "packageManager" (pnpm, ligne de ce tableau) et "engines": { "node": ">=24 <25" } ; pnpm install --frozen-lockfile réussit sur un clone propre.
  • AC-9.1-02 — Aucune dépendance de la liste d'exclusion n'apparaît dans package.json.
  • AC-9.1-03 — pnpm-lock.yaml est commité ; le Dockerfile utilise --frozen-lockfile (§14).
  • AC-9.1-04 — Aucun fichier du dépôt, hors package.json, le lockfile et DECISIONS.md, ne répète un numéro de version de dépendance (vérifié par un test de recherche textuelle dans README.md, src/ et docs/ ; DECISIONS.md peut citer la ligne retenue en cas de repli TypeScript).

9.2 Architecture d'ensemble #

9.2.1 Vue d'ensemble #

                         Visiteur (navigateur)
                                │  HTTPS
                                ▼
        ┌───────────────────────────────────────────────────┐
        │ Reverse proxy TLS                                 │
        │  • Caddy (profil Compose « proxy »)  — ou —       │
        │  • proxy de Coolify (ressource Docker Compose)    │
        └───────────────────────┬───────────────────────────┘
                                │ HTTP 3000, réseau interne,
                                │ X-Forwarded-For réécrit par le proxy
                                ▼
 ┌───────────────── conteneur « web » (Next.js standalone, utilisateur non root) ──────────────┐
 │                                                                                             │
 │  Pages statiques (HTML généré au build)         Route Handlers (runtime nodejs)             │
 │  • /                                            • POST /api/contact                         │
 │  • /mentions-legales                            • GET  /api/health                          │
 │  • /politique-de-confidentialite                          │                                 │
 │  • /sitemap.xml, /robots.txt, 404                         ▼                                 │
 │            │                                   modules serveur  (import 'server-only')      │
 │            │ îlots clients hydratés            ├─ lib/contact/handler (pipeline §9.4)       │
 │            ▼                                   ├─ lib/rate-limit/     (mémoire, §9.6)       │
 │   navigateur : fetch('/api/contact') ─────────►├─ lib/email/          (EmailProvider, §9.5) │
 │                                                └─ lib/log/            (JSON stdout, §9.7)   │
 └───────────────────────────────────────────────────────┬─────────────────────────────────────┘
                                                         │ HTTPS, fetch natif, délai 8 s
                                                         ▼
                                            api.brevo.com/v3/smtp/email  ──►  boîte de Malka
                                                                              (+ accusé au visiteur)

 Liens sortants (aucun SDK, aucun embed) :  navigateur ──► wa.me (WhatsApp)
 Optionnel (ANALYTICS_PROVIDER=umami)    :  navigateur ──► instance Umami (script + envoi d'événements)

Principes structurants :

  1. Tout ce qui peut être statique l'est. Le contenu est résolu au build ; le HTML de / contient déjà tous les textes, prix et liens WhatsApp. Le JavaScript n'ajoute que l'interactivité.
  2. Une seule surface serveur dynamique : POST /api/contact (et GET /api/health). Aucune autre route ne lit de cookie, d'en-tête de requête ou de paramètre d'URL côté serveur.
  3. Aucun état persistant. Pas de base de données ; l'état en mémoire (limiteur de débit, déduplication) est éphémère et reconstruit à chaque démarrage (§9.6).
  4. Frontière nette UI/serveur. Les composants ne connaissent ni Brevo, ni les secrets, ni le limiteur ; ils parlent à l'API par le contrat de §9.4.

9.2.2 Stratégie de rendu #

Route Mode Réglage explicite Remarques
/ Génération statique (SSG) export const dynamic = 'force-static' Aucune API dynamique (cookies(), headers(), searchParams côté serveur) n'est autorisée dans l'arbre de / ; le paramètre offre n'est lu que côté client, une seule fois au montage du formulaire, par le mécanisme de préremplissage (§6.5.4, §6.9.7) ; la situation choisie dans la boussole ne figure dans aucune URL
/mentions-legales, /politique-de-confidentialite SSG force-static Contenu issu de legal.ts et site.ts ; la section « mesure d'audience » de la politique ne se rend que si l'analytique est activée au build (§10.6.4)
not-found (404), error, global-error Rendu standard Next.js — Spécifiés en §6.11
/sitemap.xml, /robots.txt Statique force-static app/sitemap.ts, app/robots.ts (§11) ; le plan du site liste toujours les trois URL ; hors production, la fermeture aux moteurs repose sur robots.txt (Disallow: /), la balise noindex et l'en-tête X-Robots-Tag (§11.1.7)
POST /api/contact Dynamique export const runtime = 'nodejs', export const dynamic = 'force-dynamic' Jamais en cache ; pipeline de §9.4
GET /api/health Dynamique runtime = 'nodejs', dynamic = 'force-dynamic' Cache-Control: no-store

Pas d'ISR, pas de revalidate, pas d'Edge runtime, pas de Server Actions (le formulaire utilise fetch vers une route REST documentée, plus simple à tester et à protéger). Le contenu change uniquement par un nouveau build (§9.3.9).

9.2.3 Découpage composants serveur / îlots clients #

Règle : tout composant est un composant serveur sauf s'il figure dans la liste fermée ci-dessous. Un fichier ne porte 'use client' que s'il utilise un état, un effet, un gestionnaire d'événement ou une API navigateur. Les composants serveur passent aux îlots uniquement des props sérialisables (textes, nombres, URL déjà calculées) : un îlot n'importe jamais getContent ni src/content/** (le contenu entier ne doit pas entrer dans le bundle client).

# Îlot client (export — fichier de §12.2) Raison Section
1 Providers (components/layout/providers.tsx : LazyMotion + MotionConfig reducedMotion="user") Contexte Motion §8
2 DesktopNav et MobileMenu (components/layout/desktop-nav.tsx, mobile-menu.tsx ; la coquille site-header.tsx reste un composant serveur) IntersectionObserver, état, focus piégé §6.1
3 HeroLandscape, HeroPortrait, HeroClarteWord, HeroTrustFigures (components/sections/hero/) useScroll, useTransform, useInView, compteurs §6.2
4 MethodCompass et MethodStepPanel (components/sections/method/) État, clavier, tactile, hashchange §6.4
5 OrientationCompass (components/sections/orientation/orientation-compass.tsx) État de sélection §6.6
6 TestimonialsSlider (components/sections/testimonials/testimonials-slider.tsx ; rendu seulement si ≥ 2 témoignages publiables) État, clavier §6.8
7 ContactForm et sous-composants (C) de components/forms/ État, fetch, focus §6.9
8 FooterRiver (components/sections/footer/footer-river.tsx) useInView, ambiance animée §6.10
9 Primitives d'animation de components/motion/ (Reveal, RevealLines, StaggerGroup, ParallaxLayer, AmbientDrift, CountUp, StableSwap) Hooks Motion §8
10 CtaLink (lien d'action qui appelle track() au clic ; components/ui/cta-link.tsx ; seul composant de lien mesuré) Gestionnaire de clic §9.8
11 SectionErrorBoundary (classe React ; voir §12.2) Capture d'erreurs de rendu §9.7.5
12 AnalyticsOptOut (page de politique de confidentialité, seulement si analytique activée ; voir §12.2) localStorage : indicateur umami.disabled écrit uniquement à la demande explicite du visiteur §10.6.4
13 error.tsx et global-error.tsx Exigence Next.js (composants clients) §6.11

Règles complémentaires :

  • Les cartes d'offres, la philosophie, l'à-propos, les pages légales et la structure du footer sont rendus côté serveur ; seuls les liens d'action mesurés passent par CtaLink (§9.8), îlot minimal qui n'embarque ni contenu ni logique métier. Les arborescences détaillées (hooks, primitives de mouvement, sous-composants de formulaire marqués (C)) figurent en §12.2 ; elles se rattachent toutes à un îlot de la liste. Les liens WhatsApp calculés par getBookingAction (serveur uniquement, §9.9) sont transmis aux îlots sous forme d'attributs href en props : aucun îlot n'importe src/lib/booking/index.ts.
  • Aucun îlot n'utilise next/dynamic avec ssr: false : le HTML serveur doit contenir le contenu final (SEO, LCP, zéro décalage de mise en page).
  • Le budget de JavaScript initial est fixé en §11 ; ajouter un îlot à la liste ci-dessus requiert une entrée dans DECISIONS.md.

9.2.4 Séparation UI / logique serveur #

Emplacement Contenu Contraintes
Fichiers 'use client' de src/components/** (îlots (C) de §12.2) et src/hooks/** Interface interactive N'importent jamais un module serveur de cette table, ni node:*, ni src/lib/content/**, ni src/content/**
Composants serveur de src/components/** et src/app/**/page.tsx Interface rendue au build Peuvent lire le contenu (getContent) et appeler getBookingAction ; n'importent aucun autre module serveur de cette table, ni node:*
src/lib/env.ts, src/lib/log/**, src/lib/email/**, src/lib/rate-limit/**, src/lib/server/**, src/lib/contact/handler.ts, src/lib/booking/index.ts, src/lib/seo/**, src/lib/analytics/umami-script.tsx Variables d'environnement, journalisation, e-mail, limiteur, assistants de Route Handlers (identifiant de requête, IP, origine, enveloppe d'erreur), pipeline de contact Chaque fichier commence par import 'server-only'; ; importés par src/app/** (routes, layout, pages) et src/instrumentation.ts ; src/lib/booking/index.ts, src/lib/seo/** et src/lib/analytics/umami-script.tsx sont aussi importables par les composants serveur (matrice complète : §12.3.2)
src/lib/contact/schema.ts, src/lib/contact/submit.ts, src/lib/analytics/index.ts, src/lib/analytics/events.ts, src/lib/booking/whatsapp.ts, src/lib/booking/types.ts, src/lib/format/**, src/lib/security/headers.ts Modules isomorphes purs (le constructeur d'URL WhatsApp et les types de réservation sont sûrs côté client ; la fonction publique getBookingAction, qui charge le contenu, reste côté serveur) Aucun import de node:*, de server-only ni de process.env (headers.ts reçoit ses paramètres en argument)
src/lib/content/** Schémas, chargeur, gardes Sans server-only (les scripts tsx et les tests le chargent) ; jamais importé par un îlot client (les îlots reçoivent des props) ; aucune lecture de process.env (l'environnement est passé en argument aux gardes)

Application : règle ESLint no-restricted-imports (frontières ci-dessus), plus un test d'intégration qui parcourt .next/static/** après build et échoue si l'un des motifs suivants apparaît : BREVO_API_KEY, LOG_SALT, api.brevo.com, xkeysib-. Les valeurs secrètes ne sont lues que par getServerEnv() (§13.4), appelée seulement depuis les modules serveur de la table et src/instrumentation.ts.

9.2.5 Flux de données : offre → CTA → WhatsApp #

BUILD
  src/content/fr/offers.ts ──(parse Zod, §9.3)──► Offer[] triées par `order`
  src/content/fr/site.ts   ──► contact.whatsappE164Digits
         │
         ▼
  OffersSection (composant serveur)
         │  pour chaque offre : getBookingAction({ offerId })      (src/lib/booking, §9.9)
         ▼
  { kind:'whatsapp',
    href:'https://wa.me/<digits>?text=<encodeURIComponent(offer.whatsappMessage)>',
    label }
         │
         ▼
  HTML statique : <CtaLink cta="offer_whatsapp" offerId="<id>" href=… target="_blank"
                          rel="noopener noreferrer"> + texte masqué
                          « (ouvre WhatsApp dans un nouvel onglet) »

NAVIGATEUR
  clic ──► CtaLink appelle track('cta_click', { cta:'offer_whatsapp', offer_id })
           (sans effet si ANALYTICS_PROVIDER=none)                        (§9.8)
       └──► nouvel onglet wa.me (message prérempli) — aucune donnée n'est envoyée au serveur du site

Variante formulaire : le lien « Écrire via le formulaire » d'une offre a pour href /?offre=<offerId>#contact (il fonctionne sans JavaScript ; mécanisme de préremplissage de §6.5.4). Au chargement dur de cette URL, le formulaire lit offre une seule fois au montage, après validation contre la liste fermée des identifiants d'offres de offers.ts, puis appelle history.replaceState pour retirer le paramètre de l'URL ; une valeur inconnue est ignorée silencieusement (§10.2, T-10). Au clic dans la page, requestContactPrefill({ offerId?, situationId? }) (mémoire du module + événement contact:prefill, §6.5.4) préremplit les listes du formulaire sans toucher à l'URL ; la situation choisie dans la boussole (§6.6) ne figure jamais dans une URL. Le champ « message » n'est jamais prérempli.

Règle invariante : aucun composant ne construit d'URL WhatsApp ni de message ; seul src/lib/booking/ le fait (§9.9).

Critères d'acceptation 9.2

  • AC-9.2-01 — Après next build, le manifeste de prérendu (.next/prerender-manifest.json) liste /, /mentions-legales et /politique-de-confidentialite comme routes statiques, et /api/contact et /api/health n'y figurent pas (routes dynamiques).
  • AC-9.2-02 — Tout fichier portant 'use client' est marqué (C) dans l'arborescence de §12.2 et se rattache à un îlot du §9.2.3 (test de recherche textuelle sur la liste de fichiers de §12.2).
  • AC-9.2-03 — Aucun fichier 'use client' ni aucun fichier de src/hooks/** n'importe un module serveur de la table du §9.2.4, ni src/lib/content/**, ni src/content/** ; aucun composant n'importe un module serveur autre que ceux que la table lui autorise (règle ESLint, 0 violation).
  • AC-9.2-04 — Chaque fichier serveur de la table du §9.2.4 commence par import 'server-only'; (test).
  • AC-9.2-05 — Le HTML statique de / contient, sans JavaScript, tous les liens WhatsApp des offres avec leur message encodé.
  • AC-9.2-06 — Le bundle client ne contient aucun des motifs interdits du §9.2.4.

9.3 Modèle de contenu #

9.3.1 Principe et organisation #

Tout le contenu éditable vit dans des fichiers TypeScript typés sous src/content/fr/, validés par Zod au build. Aucune base de données, aucun CMS. Les valeurs textuelles (copy) sont rédigées en §7 ; la présente section définit les formes, les contraintes et les règles croisées. Propriété des clés : les schémas de §9.3.3 sont le propriétaire unique de toutes les clés de contenu, y compris les libellés d'interface (boutons, aides, messages, textes pour lecteurs d'écran) consommés par §6. §7 fournit, pour chaque fichier de src/content/fr/, un littéral qui reprend ces clés une à une, sans clé en plus ni en moins ; les interfaces des sous-sections « Source de contenu » de §6 sont des vues en lecture de ces schémas et renvoient ici au lieu de les redéfinir.

Fichier Contenu Export Schéma
site.ts Marque, coordonnées, chiffres de confiance site siteSchema
navigation.ts Liens de navigation, CTA, libellés du menu navigation navigationSchema
hero.ts Titre, description, CTA, calques de paysage, portrait hero heroSchema
philosophy.ts Section « Entreprise vivante » philosophy philosophySchema
method.ts Boussole de clarté (4 étapes) method methodSchema
offers.ts Offres, prix, messages WhatsApp offers offersSchema
orientation.ts Boussole d'orientation (4 situations) orientation orientationSchema
about.ts Présentation de Malka about aboutSchema
testimonials.ts Témoignages et consentements testimonials testimonialsSchema
contact.ts Textes du formulaire, messages d'erreur, carte WhatsApp contact contactSchema
footer.ts Footer « Passage & Clarté » footer footerSchema
legal.ts Identité légale, hébergeur, TVA, médiateur legal legalSchema
seo.ts Métadonnées et Open Graph seo seoSchema

Fichiers de code associés (arborescence : §12.2) : src/lib/content/schemas.ts (tous les schémas et constantes fermées), src/lib/content/types.ts (types inférés ; seul import autorisé depuis un îlot client), src/lib/content/index.ts (chargeur getContent), src/lib/content/guards.ts (vérifications croisées et gardes de production), src/lib/content/testimonials.ts (isPublishable, getPublishableTestimonials), src/lib/format/price.ts (formatPrice).

9.3.2 Primitives et constantes fermées #

// src/lib/content/schemas.ts
import { z } from 'zod';

/* ───────── Constantes fermées (source de vérité des identifiants) ───────── */
export const LOCALES = ['fr'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'fr';

export const SECTION_IDS = [
  'accueil', 'philosophie', 'methode', 'accompagnements',
  'orientation', 'a-propos', 'temoignages', 'contact',
] as const;
export type SectionId = (typeof SECTION_IDS)[number];

export const NAV_ITEM_IDS = [
  'philosophie', 'methode', 'accompagnements', 'orientation', 'a-propos', 'temoignages', 'contact',
] as const;

export const METHOD_STEP_IDS = ['ecouter', 'explorer', 'clarifier', 'decider'] as const;
export type MethodStepId = (typeof METHOD_STEP_IDS)[number];

export const SITUATION_IDS = ['decision-bloquee', 'fatigue', 'repositionnement', 'transition'] as const;
export type SituationId = (typeof SITUATION_IDS)[number];

/** Seules statistiques autorisées : 300+ accompagnés, 50+ formés. */
export const TRUST_FIGURE_IDS = ['accompagnes', 'formes'] as const;

export const NON_DEFINED_OFFER = 'non-defini' as const;

export const API_ERROR_CODES = [
  'VALIDATION_ERROR', 'INVALID_JSON', 'UNSUPPORTED_MEDIA_TYPE', 'PAYLOAD_TOO_LARGE', 'FORBIDDEN_ORIGIN',
  'RATE_LIMITED', 'EMAIL_DELIVERY_FAILED', 'INTERNAL_ERROR', 'METHOD_NOT_ALLOWED',
] as const;

/* ───────── Primitives ───────── */
// Caractères refusés dans tout texte : contrôles C0 et DEL, espaces et marques invisibles U+200B–200F,
// séparateurs de ligne et de paragraphe U+2028–2029, marques bidirectionnelles U+202A–202E et U+2066–2069, U+FEFF.
const CONTROL_CHARS = /[\u0000-\u001F\u007F\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/;
const MARKUP = /<[a-z!/]/i;

/** Texte d'une seule ligne : rogné, sans caractère de contrôle ni marque bidirectionnelle, sans balise. */
export const text = (min: number, max: number) =>
  z.string().trim().min(min).max(max)
    .refine((v) => !CONTROL_CHARS.test(v), 'caractère de contrôle interdit')
    .refine((v) => !MARKUP.test(v), 'balise HTML interdite');

/** Même contrôle que `text()`, mais la chaîne vide est admise : citation d'un témoignage non publié, note de vérification,
 *  champs légaux fournis plus tard par Malka, texte alternatif d'une image décorative. */
export const textOrEmpty = (max: number) => text(0, max);

// PLACEHOLDER_PATTERN (src/lib/content/placeholders.ts) n'est utilisé que par les gardes de production (§9.3.6).

export const slug = z.string().min(2).max(60)
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'identifiant invalide (a-z, 0-9, tirets)');

/** Lien interne uniquement : `#ancre`, `/chemin`, `/chemin#ancre`. Refuse `//hôte`, `http:`, `javascript:`. */
export const internalHref = z.string().min(1).max(120)
  .regex(/^(?:\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?)?(?:#[a-z0-9-]+)?$/, 'lien interne invalide');

export const isoDate = z.iso.date();                 // 'YYYY-MM-DD', jour réel du calendrier

export const countSentences = (s: string): number =>   // terminaisons . ! ? …
  (s.match(/[^.!?…]+[.!?…]+(?=\s|$)/g) ?? []).length;

/* ───────── Emplacements d'image ─────────
 * Un emplacement = un fichier unique, déposé tel quel dans `public/images/<dossier>/` (ou `public/og/`) et servi par `next/image` (§12.10).
 * En développement, un emplacement peut être de type `svg` (visuels `dev-fallback`) ; le format raster, les dimensions minimales
 * et le ratio sont des gardes de production (G-07, §9.3.7), pas des contraintes du schéma. */
const dims = { width: z.int().min(16).max(8000), height: z.int().min(16).max(8000) };
const rasterSlot = z.strictObject({
  kind: z.literal('raster'),
  src: z.string().regex(/^\/(?:images|og)\/[a-z0-9][a-z0-9/_-]*\.(?:webp|avif|png|jpe?g)$/, 'chemin ou extension invalide'),
  ...dims,
  alt: textOrEmpty(200),
});
const svgSlot = z.strictObject({
  kind: z.literal('svg'),
  src: z.string().regex(/^\/images\/[a-z0-9][a-z0-9/_-]*\.svg$/),
  ...dims,
  alt: textOrEmpty(200),
});
export const imageSlot = z.discriminatedUnion('kind', [rasterSlot, svgSlot]);
export const decorativeSlot = imageSlot.refine((s) => s.alt === '', 'image décorative : alt doit être vide');
export const informativeSlot = imageSlot.refine(
  (s) => s.alt.length >= 10 && s.alt.length <= 140 && !/^(image|photo) de /i.test(s.alt),
  'alt descriptif de 10 à 140 caractères, sans « Image de » ni « Photo de »');
export type ImageSlot = z.output<typeof imageSlot>;

Tous les champs texte de tous les fichiers passent par text(min, max) ou, lorsque la chaîne vide est admise (citation d'un témoignage non publié, note de vérification, champs légaux, texte alternatif), par textOrEmpty(max) : pas de HTML, pas de caractère de contrôle ni de marque invisible ou d'inversion bidirectionnelle (U+200B–200F, U+2028–2029, U+202A–202E, U+2066–2069, U+FEFF), longueurs bornées. Aucun contenu n'est rendu avec dangerouslySetInnerHTML (règle ESLint, §12.8) ; le bloc JSON-LD (§11) est sérialisé par JSON.stringify.

9.3.3 Schémas des fichiers de contenu #

Les formes ci-dessous sont celles consommées par les composants de §6 (les interfaces de §6 en sont des vues en lecture). Chaque fichier de src/content/fr/ est écrit avec satisfies ContentInput['<clé>'] pour obtenir l'erreur dans l'éditeur avant le build.

/* ───────── site.ts ───────── */
export const siteSchema = z.strictObject({
  brandName: text(2, 60),
  tagline: text(10, 160),
  contact: z.strictObject({
    phoneDisplay: z.string().regex(/^0[1-9]( \d{2}){4}$/),      // « 06 67 29 51 79 »
    phoneE164: z.string().regex(/^\+33[1-9]\d{8}$/),            // « +33667295179 »
    whatsappE164Digits: z.string().regex(/^33[1-9]\d{8}$/),     // « 33667295179 »
    email: z.email().max(254),
    location: text(5, 80),                                      // « France — accompagnement à distance »
  }).superRefine((c, ctx) => {
    const fromDisplay = '33' + c.phoneDisplay.replace(/ /g, '').slice(1);
    if (fromDisplay !== c.whatsappE164Digits)
      ctx.addIssue({ code: 'custom', path: ['whatsappE164Digits'], message: 'incohérent avec phoneDisplay' });
    if (c.phoneE164 !== '+' + c.whatsappE164Digits)
      ctx.addIssue({ code: 'custom', path: ['phoneE164'], message: 'incohérent avec whatsappE164Digits' });
  }),
  trustFigures: z.array(z.strictObject({
    id: z.enum(TRUST_FIGURE_IDS),                               // verrouille les seules statistiques autorisées
    value: z.int().positive().max(100000),
    suffix: z.enum(['', '+']),
    label: text(3, 60),
    ariaLabel: text(5, 100).optional(),                         // « Plus de 300 personnes accompagnées »
  })).max(2),
});

/* ───────── navigation.ts ───────── */
export const navigationSchema = z.strictObject({
  skipLink: text(3, 40),                                        // « Aller au contenu »
  links: z.array(z.strictObject({
    id: z.enum(NAV_ITEM_IDS),
    label: text(2, 30),
    requires: z.literal('publishableTestimonials').optional(),  // seul « temoignages » le porte
  })).length(NAV_ITEM_IDS.length),
  cta: z.strictObject({ label: text(3, 40), targetId: z.literal('contact') }),
  menu: z.strictObject({ openLabel: text(3, 40), closeLabel: text(3, 40), dialogLabel: text(3, 40), navLabel: text(3, 40) }),
  logo: z.strictObject({ src: z.string().regex(/^\/images\/logo\/[a-z0-9-]+\.svg$/), width: z.int().positive(), height: z.int().positive() }).optional(),
}).superRefine((n, ctx) => {
  n.links.forEach((l, i) => {
    if (l.id !== NAV_ITEM_IDS[i]) ctx.addIssue({ code: 'custom', path: ['links', i, 'id'], message: `ordre attendu : ${NAV_ITEM_IDS.join(', ')}` });
    if ((l.id === 'temoignages') !== (l.requires === 'publishableTestimonials'))
      ctx.addIssue({ code: 'custom', path: ['links', i, 'requires'], message: 'requires: publishableTestimonials est réservé au lien « temoignages » (et obligatoire pour lui)' });
  });
});

/* ───────── hero.ts ───────── */
export const heroSchema = z.strictObject({
  eyebrow: text(3, 90),
  headlineLines: z.tuple([text(1, 40), text(1, 40), text(1, 40)]),      // une entrée = une ligne du masque de révélation
  description: text(40, 240),
  primaryCta: z.strictObject({ label: text(3, 40), targetId: z.literal('contact') }),
  secondaryCta: z.strictObject({ label: text(3, 40), targetId: z.literal('methode') }),
  bigWord: z.string().regex(/^[A-ZÀ-ÖØ-Þ]{3,12}$/),                    // « CLARTÉ »
  railLabel: text(3, 80),
  images: z.strictObject({
    far: decorativeSlot, mid: decorativeSlot, near: decorativeSlot,   // calques du paysage, ≥ 1024 px : hero-far, hero-mid, hero-near
    mobile: decorativeSlot,                                           // bande paysage aplatie, sous 1024 px : hero-mobile
    portrait: informativeSlot,                                        // découpe de Malka : hero-portrait
  }),
});
// Les chiffres de confiance affichés par le hero sont ceux de site.trustFigures (aucune copie).

/* ───────── philosophy.ts ───────── */
export const philosophySchema = z.strictObject({
  eyebrow: text(3, 60),
  titleLines: z.tuple([text(2, 40), text(2, 40)]),                      // ['Entreprise', 'vivante']
  lead: text(40, 260),
  body: z.array(text(60, 600)).max(3),
  pullQuote: text(20, 140),                                             // thèse non attribuée (pas un témoignage)
  principles: z.tuple([principle(), principle(), principle()]),
});
function principle() {
  return z.strictObject({ id: slug, title: text(3, 24), text: text(40, 160) });
}

/* ───────── method.ts ───────── */
const methodStep = z.strictObject({
  id: z.enum(METHOD_STEP_IDS),
  label: text(3, 20),
  verb: text(5, 70),
  summary: text(20, 240),
  details: z.array(text(5, 160)).min(2).max(4),
  whatYouGet: text(10, 180),                                            // jamais une promesse de résultat
});
export const methodSchema = z.strictObject({
  eyebrow: text(3, 60),
  title: text(5, 90),
  intro: text(40, 280),
  tablistLabel: text(5, 80),
  instruction: text(10, 160),
  previewHint: text(5, 100),
  whatYouGetLabel: text(5, 60),
  closing: z.strictObject({ label: text(3, 60), targetId: z.literal('accompagnements') }),
  steps: z.tuple([methodStep, methodStep, methodStep, methodStep]),
}).superRefine((m, ctx) => {
  m.steps.forEach((s, i) => {
    if (s.id !== METHOD_STEP_IDS[i]) ctx.addIssue({ code: 'custom', path: ['steps', i, 'id'], message: `ordre attendu : ${METHOD_STEP_IDS.join(', ')}` });
  });
});

/* ───────── offers.ts ───────── */
export const priceSchema = z.discriminatedUnion('kind', [
  z.strictObject({ kind: z.literal('fixed'), amountEur: z.int().min(1).max(100000) }),
  z.strictObject({ kind: z.literal('quote'), label: text(1, 30) }),
]);
export const offerSchema = z.strictObject({
  id: slug,
  title: text(3, 80),
  shortDescription: text(20, 220),
  details: z.array(text(1, 140)).max(5),
  duration: text(1, 40).optional(),            // jamais renseignée sans information fournie par Malka
  format: text(1, 60),
  price: priceSchema,
  whatsappMessage: text(10, 300)
    .refine((m) => encodeURIComponent(m).length <= 1900, 'message trop long une fois encodé'),
  featured: z.boolean(),
  order: z.int().min(1),
});
export const offersSchema = z.strictObject({
  eyebrow: text(3, 60),
  title: text(5, 90),
  intro: text(20, 400),
  featuredLabel: text(3, 40),
  priceLabel: text(3, 20),                      // « Tarif »
  detailOpenLabel: text(3, 40),
  detailCloseLabel: text(3, 40),
  formLinkLabel: text(3, 60),
  ctaLabels: z.strictObject({ whatsapp: text(3, 40), whatsappNewTabSuffix: text(5, 60) }),
  orientationHint: z.strictObject({ text: text(10, 160), linkLabel: text(3, 60) }),
  offers: z.array(offerSchema).min(1).max(6),   // ≥ 1 offre : garde G-11
});

/* ───────── orientation.ts ───────── */
export const situationSchema = z.strictObject({
  id: z.enum(SITUATION_IDS),
  label: text(3, 28),
  prompt: text(80, 700).refine((p) => { const n = countSentences(p); return n >= 2 && n <= 4; }, '2 à 4 phrases exigées'),
  questions: z.array(text(10, 200)).min(2).max(3),
  suggestedOfferId: slug,                       // vérifié contre offers.offers (§9.3.5)
  suggestedOfferSentence: text(20, 240),        // phrase d'accompagnement suggérée, affichée avec l'offre proposée
  disclaimer: text(20, 240),                    // avertissement propre à la situation
  whatsappMessage: text(20, 300).refine((m) => m.startsWith('Bonjour Malka'), 'doit commencer par « Bonjour Malka »'),
});
export const orientationSchema = z.strictObject({
  eyebrow: text(3, 60).optional(),
  title: text(5, 90),
  intro: text(20, 300),
  groupLabel: text(5, 120),                     // légende visible du groupe de choix (fieldset / legend)
  emptyState: text(10, 160),                    // texte du panneau tant qu'aucune situation n'est choisie
  resultHeadings: z.strictObject({ reflection: text(3, 60), questions: text(3, 60), offer: text(3, 60) }),   // intertitres du résultat
  globalDisclaimer: text(30, 240).refine((d) => /diagnostic/i.test(d) && /résultat/i.test(d),
    'doit mentionner « diagnostic » et « résultat »'),
  resetLabel: text(3, 60),
  ctaLabels: z.strictObject({ whatsapp: text(3, 40), whatsappNewTabSuffix: text(5, 60), form: text(3, 60) }),
  liveRegion: z.strictObject({ situationSelected: text(10, 160), selectionReset: text(10, 160) }),  // {label} interpolé côté client
  situations: z.tuple([situationSchema, situationSchema, situationSchema, situationSchema]),
}).superRefine((o, ctx) => {
  o.situations.forEach((s, i) => {
    if (s.id !== SITUATION_IDS[i]) ctx.addIssue({ code: 'custom', path: ['situations', i, 'id'], message: `ordre attendu : ${SITUATION_IDS.join(', ')}` });
  });
});

/* ───────── about.ts ───────── */
export const aboutSchema = z.strictObject({
  eyebrow: text(3, 60),
  title: text(3, 90),
  portrait: informativeSlot,                                   // le format 4:5 et les dimensions minimales sont vérifiés par la garde G-07 (production)
  bio: z.strictObject({
    paragraphs: z.array(text(80, 900)).max(4).refine((p) => p.join('').length <= 2200, 'total ≤ 2200 caractères'),
    devPlaceholder: text(5, 120).optional(),                 // affiché hors production tant que `paragraphs` est vide
  }),
  posture: z.strictObject({ title: text(3, 60), body: text(60, 400) }),
  approach: z.strictObject({ title: text(3, 60), body: text(60, 400) }),
});

/* ───────── testimonials.ts ───────── */
export const testimonialSchema = z.strictObject({
  id: slug,
  authorName: text(2, 60),
  authorContext: text(1, 80).optional(),
  quote: textOrEmpty(700),                                        // vide autorisé tant que non publié ; non vide dès que `published` vaut true
  offerId: slug.optional(),
  consent: z.strictObject({
    status: z.enum(['granted', 'pending']),
    grantedOn: isoDate.optional(),
    channel: text(2, 80).optional(),
  }),
  published: z.boolean(),
}).superRefine((t, ctx) => {
  if (!t.published) return;
  if (t.consent.status !== 'granted') ctx.addIssue({ code: 'custom', path: ['consent', 'status'], message: 'published: true exige consent.status = granted' });
  if (!t.consent.grantedOn) ctx.addIssue({ code: 'custom', path: ['consent', 'grantedOn'], message: 'published: true exige consent.grantedOn' });
  else if (t.consent.grantedOn > new Date().toISOString().slice(0, 10)) ctx.addIssue({ code: 'custom', path: ['consent', 'grantedOn'], message: 'date de consentement dans le futur' });
  if (t.quote.length < 1) ctx.addIssue({ code: 'custom', path: ['quote'], message: 'published: true exige une citation non vide (700 caractères au plus)' });
});
export const testimonialsSchema = z.strictObject({
  eyebrow: text(3, 60).optional(),
  title: text(3, 90),
  intro: text(10, 240).optional(),
  verificationNote: textOrEmpty(400),                             // non vide dès qu'un témoignage est publiable (X-10)
  regionAriaLabel: text(3, 80),
  roleDescriptions: z.strictObject({ carousel: text(3, 30), slide: text(3, 30) }),                                     // aria-roledescription du conteneur et de chaque citation
  slideLabel: text(5, 80),                                                                                             // {n}, {total}
  offerTagLabel: text(3, 60),                                                                                          // étiquette de l'accompagnement concerné
  controls: z.strictObject({ previousLabel: text(3, 60), nextLabel: text(3, 60), paginationAriaLabel: text(5, 100) }),   // {n}, {total}
  liveRegion: z.strictObject({ slideChanged: text(5, 120) }),                                                          // {n}, {total}, {authorName}
  authorDisplay: z.strictObject({ withContext: text(5, 80), withoutContext: text(5, 40) }),                            // {authorName}, {authorContext}
  items: z.array(testimonialSchema).max(12),
});

/* ───────── contact.ts ───────── */
export const CONTACT_API_ERROR_KEYS = [...API_ERROR_CODES, 'OFFLINE', 'NETWORK_ERROR', 'TIMEOUT'] as const;   // les trois dernières sont propres au client (aucune réponse HTTP)
const fieldCopy = z.strictObject({ label: text(2, 160), hint: text(1, 200).optional() });   // aucun texte indicatif (`placeholder`) : l'aide passe par `hint`
const rule = (...keys: string[]) => z.strictObject(Object.fromEntries(keys.map((k) => [k, text(5, 200)])));
export const contactValidationMessagesSchema = z.strictObject({
  name: rule('required', 'tooShort', 'tooLong', 'invalid'),
  email: rule('required', 'invalid', 'tooLong'),
  phone: rule('invalid', 'tooLong'),
  offerId: rule('invalid'),
  situationId: rule('invalid'),
  message: rule('required', 'tooShort', 'tooLong', 'tooManyLinks'),
  consent: rule('required'),
  generic: text(10, 200),                                          // erreur de formulaire / champs techniques
});
export type ContactValidationMessages = z.output<typeof contactValidationMessagesSchema>;
export const contactSchema = z.strictObject({
  eyebrow: text(3, 60),
  title: text(3, 90),
  intro: text(20, 400),
  whatsappCard: z.strictObject({ title: text(3, 60), text: text(10, 240), ctaLabel: text(3, 40), ctaNewTabSuffix: text(5, 60), defaultMessage: text(20, 300) }),
  form: z.strictObject({
    title: text(3, 90),                                            // titre h3 du formulaire
    intro: text(10, 240),                                          // précise que les champs non marqués « facultatif » sont obligatoires
    dataNote: text(10, 240),                                       // légende sous le bouton d'envoi
  }),
  fields: z.strictObject({
    optionalSuffix: text(3, 30),                                   // « (facultatif) », ajouté au libellé des champs facultatifs
    name: fieldCopy, email: fieldCopy, phone: fieldCopy, message: fieldCopy,
    offerId: fieldCopy.extend({ noneOptionLabel: text(3, 60) }),       // option de valeur `non-defini`
    situationId: fieldCopy.extend({ noneOptionLabel: text(3, 60) }),   // option de valeur vide ; liste visible et modifiable (jamais cachée)
    consent: z.strictObject({
      label: text(20, 300).refine((l) => l.split('{privacyLink}').length === 2, 'doit contenir exactement un {privacyLink}'),
      privacyLinkLabel: text(3, 60),
      privacyLinkNewTabSuffix: text(5, 60),                        // « (s'ouvre dans un nouvel onglet) », texte masqué
    }),
    website: z.strictObject({ label: text(2, 40) }),               // libellé interne du champ piège (jamais annoncé)
    characterCount: text(3, 40).refine((c) => c.includes('{n}') && c.includes('{max}'), 'doit contenir {n} et {max}'),
  }),
  prefillAnnouncement: z.strictObject({ byOffer: text(10, 200), bySituation: text(5, 200) }),   // {offerTitle}, {situationLabel} — annonce vocale ; le champ « message » n'est jamais prérempli
  validation: contactValidationMessagesSchema,
  errorSummary: z.strictObject({ titleOne: text(5, 80), titleMany: text(5, 80) }),               // {n}
  failureBanner: z.strictObject({
    title: text(3, 60), fallbackIntro: text(10, 160), retryLabel: text(3, 40),
    emailLinkLabel: text(3, 40), emailSubject: text(3, 80),
  }),
  retryAfter: z.strictObject({                                     // durée lisible du délai d'un 429 ; {n}
    underMinute: text(5, 40), minuteOne: text(5, 40), minuteMany: text(5, 40),
    hourOne: text(5, 40), hourMany: text(5, 40), unknown: text(5, 40),
  }),
  submit: z.strictObject({ idleLabel: text(3, 40), loadingLabel: text(3, 40), loadingAriaLive: text(5, 120) }),
  apiErrors: z.strictObject(Object.fromEntries(CONTACT_API_ERROR_KEYS.map((k) => [k, text(10, 300)])) as Record<(typeof CONTACT_API_ERROR_KEYS)[number], ReturnType<typeof text>>),
  success: z.strictObject({ title: text(3, 60), body: text(10, 300), resetLabel: text(3, 60) }),
  noscript: z.strictObject({ text: text(10, 300) }),
});

/* ───────── footer.ts ───────── */
export const footerSchema = z.strictObject({
  wordmark: z.literal('CLARTÉ'),
  ctaCard: z.strictObject({                                        // composant unique `FloatingCtaCard` (§6.10)
    title: text(3, 80), text: text(10, 200),
    whatsappLabel: text(3, 40), whatsappNewTabSuffix: text(5, 60), // lien produit par getBookingAction (§9.9)
    formLabel: text(3, 40),                                        // lien vers /#contact
  }),
  columns: z.strictObject({
    navigation: z.strictObject({ title: text(3, 40), ariaLabel: text(3, 60) }),   // liens repris de navigation.ts
    contact: z.strictObject({ title: text(3, 40) }),               // coordonnées reprises de site.ts
    legal: z.strictObject({
      title: text(3, 40), ariaLabel: text(3, 60),
      links: z.tuple([
        z.strictObject({ label: text(3, 60), href: z.literal('/mentions-legales') }),
        z.strictObject({ label: text(3, 60), href: z.literal('/politique-de-confidentialite') }),
      ]),
    }),
  }),
  copyright: text(10, 120).refine((c) => c.includes('{year}'), 'doit contenir {year}'),
  backToTopLabel: text(3, 40),
});
// Le décor du footer (montagnes, rivière, mot géant) est entièrement vectoriel : aucun emplacement d'image, aucune licence.

/* ───────── legal.ts ───────── */
export const siret = z.string().regex(/^\d{14}$/).refine(luhn, 'SIRET invalide (clé de Luhn)');
const frPhone = z.string().regex(/^(?:0[1-9]( \d{2}){4}|\+\d{1,3}[\d ]{6,16})$/, 'numéro de téléphone invalide');
export const legalSchema = z.strictObject({
  publisher: z.strictObject({
    legalName: textOrEmpty(120),
    tradeName: textOrEmpty(120).optional(),
    legalForm: textOrEmpty(80),
    siret: z.union([z.literal(''), siret]),
    address: textOrEmpty(240),
    email: z.union([z.literal(''), z.email().max(254)]),
    phone: z.union([z.literal(''), frPhone]),
    publicationDirector: textOrEmpty(120),
    shareCapital: textOrEmpty(60).optional(),                      // société uniquement
    registration: textOrEmpty(120).optional(),                     // immatriculation (RCS/RNE) pour une société
  }),
  host: z.strictObject({ name: textOrEmpty(120), address: textOrEmpty(240), phone: z.union([z.literal(''), frPhone]) }),
  vatMention: textOrEmpty(300),
  privacy: z.strictObject({ retention: textOrEmpty(500) }),        // durée de conservation des messages : texte fourni par Malka, aucune valeur par défaut livrée (§1.3, §10.6.2)
  mediator: z.strictObject({ name: text(2, 120), url: z.url({ protocol: /^https$/ }) }).optional(),
  lastUpdated: z.union([z.literal(''), isoDate]),
});
// La complétude (champs non vides, date non future) est exigée par la garde G-01 (production), pas par le schéma :
// en développement et en staging, les valeurs vides sont tolérées et signalées par un bandeau (§6.11).
function luhn(v: string): boolean {
  let sum = 0;
  for (let i = 0; i < v.length; i++) {
    let d = Number(v[v.length - 1 - i]);
    if (i % 2 === 1) { d *= 2; if (d > 9) d -= 9; }
    sum += d;
  }
  return sum % 10 === 0;
}

/* ───────── seo.ts ───────── */
const pageMeta = z.strictObject({ title: text(20, 60), description: text(110, 160) });
export const seoSchema = z.strictObject({
  siteName: text(2, 60),
  locale: z.literal('fr_FR'),
  jobTitle: text(10, 120),                                         // intitulé de profession du balisage JSON-LD (formulation de positionnement de Malka)
  defaultOgImageAlt: text(10, 200),                                // image : /og/og-default.jpg (1200×630), non configurable
  routes: z.strictObject({
    home: pageMeta.extend({ ogTitle: text(10, 70), ogDescription: text(110, 200) }),
    mentionsLegales: pageMeta,
    politiqueConfidentialite: pageMeta,
    notFound: pageMeta,
  }),
  jsonLd: z.strictObject({ enabled: z.boolean() }).optional(),     // aucun `sameAs` : aucune présence tierce n'est inventée
});

/* ───────── Agrégat ───────── */
export const contentSchemas = {
  site: siteSchema, navigation: navigationSchema, hero: heroSchema, philosophy: philosophySchema,
  method: methodSchema, offers: offersSchema, orientation: orientationSchema, about: aboutSchema,
  testimonials: testimonialsSchema, contact: contactSchema, footer: footerSchema, legal: legalSchema, seo: seoSchema,
} as const;
export type ContentKey = keyof typeof contentSchemas;
export type Content = { [K in ContentKey]: z.output<(typeof contentSchemas)[K]> };
export type ContentInput = { [K in ContentKey]: z.input<(typeof contentSchemas)[K]> };
export type Offer = z.output<typeof offerSchema>;
export type Testimonial = z.output<typeof testimonialSchema>;
export type SiteContent = Content['site'];
export type SeoContent = Content['seo'];

Précisions :

  • Les valeurs de chaque champ sont celles de §7. Les nombres de caractères ci-dessus sont des bornes de schéma, pas des cibles éditoriales.
  • offers.offers n'est pas limité aux quatre offres livrées : Malka peut en retirer ou en ajouter (1 à 6) en respectant les règles du §9.3.5.
  • Toute chaîne qui contient des jetons d'interpolation ({label}, {n}, {max}, {total}, {authorName}, {authorContext}, {offerTitle}, {situationLabel}, {year}, {privacyLink}) est remplacée à l'affichage par le composant ; aucun jeton non résolu ne doit atteindre le HTML (test de §15).

9.3.4 Chargeur, aides et gardes #

// src/lib/content/index.ts
import { contentSchemas, DEFAULT_LOCALE, type Content, type ContentKey, type Locale } from './schemas';
import { runCrossChecks, formatGuardReport, ContentGuardError, type GuardIssue } from './guards';
import { site } from '@/content/fr/site';
import { navigation } from '@/content/fr/navigation';
import { hero } from '@/content/fr/hero';
import { philosophy } from '@/content/fr/philosophy';
import { method } from '@/content/fr/method';
import { offers } from '@/content/fr/offers';
import { orientation } from '@/content/fr/orientation';
import { about } from '@/content/fr/about';
import { testimonials } from '@/content/fr/testimonials';
import { contact } from '@/content/fr/contact';
import { footer } from '@/content/fr/footer';
import { legal } from '@/content/fr/legal';
import { seo } from '@/content/fr/seo';

const RAW: Record<Locale, Record<ContentKey, unknown>> = {
  fr: { site, navigation, hero, philosophy, method, offers, orientation, about, testimonials, contact, footer, legal, seo },
};
const cache = new Map<Locale, Content>();

/** Analyse Zod + vérifications croisées X-01…X-13 (garde G-00). Fonction pure : aucune lecture de fichier, aucune lecture de `process.env`. */
export function getContent(locale: Locale = DEFAULT_LOCALE): Content {
  const hit = cache.get(locale);
  if (hit) return hit;
  const issues: GuardIssue[] = [];
  const parsed: Record<string, unknown> = {};
  for (const key of Object.keys(contentSchemas) as ContentKey[]) {
    const r = contentSchemas[key].safeParse(RAW[locale][key]);
    if (r.success) parsed[key] = r.data;
    else r.error.issues.forEach((i) => issues.push({ guard: 'G-00', file: key, path: i.path.join('.') || '(racine)', message: i.message }));
  }
  if (issues.length === 0) issues.push(...runCrossChecks(parsed as Content));
  if (issues.length > 0) throw new ContentGuardError(formatGuardReport(issues));   // fait échouer `next build`
  cache.set(locale, parsed as Content);
  return parsed as Content;
}
// src/lib/content/guards.ts (types publics ; aucune lecture de process.env : l'environnement est passé en argument)
export interface GuardIssue { guard: string; file: string; path: string; message: string; severity?: 'error' | 'warning' }
export class ContentGuardError extends Error {}
export function runCrossChecks(content: Content): GuardIssue[];                                  // X-01 … X-13 (pur)
export function assertProductionGuards(content: Content, appEnv: AppEnv): GuardIssue[];          // G-01, qualité §9.3.6, marqueurs de fixture (pur, production seulement)
export function assertFilesystemGuards(content: Content, appEnv: AppEnv, root: string): GuardIssue[];   // G-02 (fichiers), G-05 (registre), G-07, G-08, G-09 — lit le disque
export function formatGuardReport(issues: GuardIssue[], appEnv?: AppEnv): string;                // format de §9.3.7 ; « (APP_ENV=…) » omis si l'environnement n'est pas fourni

getContent est appelé par src/app/layout.tsx, chaque page, sitemap.ts, robots.ts et scripts/check-assets.ts : un contenu invalide fait donc échouer next build quel que soit le point d'entrée. Les gardes de production (assertProductionGuards, assertFilesystemGuards) sont, elles, appelées uniquement au build, par scripts/check-assets.ts (première étape de pnpm build, §12.9, et de pnpm check:guards), qui lit APP_ENV et le transmet en argument. Elles ne s'exécutent jamais au runtime du conteneur : l'image runner ne contient ni content-validation.json, ni le registre des licences, ni les originaux, et getContent n'y lit aucun fichier (§9.3.7).

Aides :

Fonction Fichier Règle
defaultWhatsappMessage(title) src/lib/content/helpers.ts Retourne Bonjour Malka, je souhaite en savoir plus sur « ${title} ». Pouvons-nous échanger ?
formatPrice(price) src/lib/format/price.ts fixed : new Intl.NumberFormat('fr-FR', { style:'currency', currency:'EUR', maximumFractionDigits:0 }).format(amountEur) → « 1 000 € » (espace fine insécable U+202F entre milliers, espace insécable U+00A0 avant « € », aucun remplacement manuel) ; quote : label
isPublishable(t) src/lib/content/testimonials.ts t.published && t.consent.status === 'granted' && t.quote.trim().length > 0
getPublishableTestimonials(all) idem all.filter(isPublishable), ordre du fichier conservé
getRenderedSectionIds(c) src/lib/content/guards.ts Toutes les sections de SECTION_IDS, sauf temoignages si aucun témoignage publiable
getSortedOffers(c) src/lib/content/helpers.ts c.offers.offers triées par order croissant

Correspondance avec le copy deck (§7). Il n'existe pas de table de correspondance : pour chaque fichier de src/content/fr/, §7 présente un littéral dont les clés sont exactement celles du schéma de §9.3.3 (aucune clé inconnue, aucune clé requise absente). Un test unitaire parse les 13 fichiers livrés avec leur schéma (§15) ; la valeur de offers.offers[*].price est saisie sous forme de montant, et l'affichage « 1 000 € » est toujours calculé par formatPrice, jamais saisi.

9.3.5 Vérifications croisées entre fichiers #

runCrossChecks(content) (dans guards.ts) retourne la liste des violations ; chaque règle est bloquante dans tous les environnements (garde G-00) ; elles sont pures et s'exécutent aussi à chaque démarrage du chargeur.

ID Règle Violation signalée (chemin)
X-01 offers.offers[*].id uniques offers › offers.<i>.id — identifiant dupliqué « … »
X-02 offers.offers[*].order uniques (entiers ≥ 1) offers › offers.<i>.order — ordre dupliqué
X-03 Au plus une offre featured: true offers › offers.<i>.featured
X-04 orientation.situations[*].suggestedOfferId existe dans offers.offers orientation › situations.<i>.suggestedOfferId — offre inconnue « … »
X-05 testimonials.items[*].id uniques testimonials › items.<i>.id
X-06 testimonials.items[*].offerId, s'il est présent, existe dans offers.offers testimonials › items.<i>.offerId
X-07 site.trustFigures[*].id uniques site › trustFigures.<i>.id
X-08 philosophy.principles[*].id uniques (l'unicité s'entend à l'intérieur de chaque liste d'identifiants) philosophy › principles.<i>.id
X-09 Aucun lien du footer ou de la navigation ne vise une section non rendue (#temoignages sans témoignage publiable) <fichier> › <chemin> — ancre « #… » non rendue
X-10 Si ≥ 1 témoignage est publiable, testimonials.verificationNote est non vide (§10.7.3) testimonials › verificationNote
X-11 contact.whatsappCard.defaultMessage commence par « Bonjour Malka » contact › whatsappCard.defaultMessage
X-12 Chaque offers.offers[*].whatsappMessage et orientation.situations[*].whatsappMessage ne contient ni http://, ni https://, ni www. (le message prérempli ne transporte aucun lien) offers › offers.<i>.whatsappMessage
X-13 Le nombre de liens navigation.links visibles correspond aux sections rendues (même ordre que SECTION_IDS) navigation › links

9.3.6 Règles de qualité de contenu en production #

Appliquées par assertProductionGuards (au build uniquement, §9.3.4) quand APP_ENV=production (avertissement dans les autres environnements) :

  • Textes provisoires : aucune chaîne de src/content/fr/** ne correspond à PLACEHOLDER_PATTERN (src/lib/content/placeholders.ts) : texte de remplissage latin, marqueurs d'attente de rédaction en français et en anglais, formules « à compléter / renseigner / préciser / définir / déterminer », suites de X, accolades doubles, marqueurs de jeux de test [FIXTURE] et Auteur Fixture. Ce motif s'applique à tous les champs, citations de témoignages comprises. Exception : about.bio.devPlaceholder, qui n'est jamais rendu en production.
  • Promesses interdites : aucune chaîne visiteur de hero, philosophy, method, offers, orientation, about ne correspond à /\b(garanti(?:e|s|es)?|guérir|guérison|thérapie|thérapeutique|thérapeute|soigner|miracle|100\s?%|résultats? (?:assurés?|certains?))\b/i (positionnement « accompagnement vibratoire et stratégique » : aucune allégation de santé, aucune promesse de résultat). La liste vit dans src/lib/content/forbidden-claims.ts et se complète par décision écrite.
  • Dérive de statistiques : le schéma n'accepte que les identifiants accompagnes et formes ; les valeurs doivent correspondre à la validation consignée pour la clé trustFigures du registre content-validation.json (§1.3.3).

9.3.7 Gardes de build en production #

Les gardes de contenu sont implémentées dans src/lib/content/guards.ts ; les gardes d'actifs (images, licences, SVG) et les gardes qui lisent le système de fichiers le sont dans scripts/check-assets.ts (§12.11). L'un et l'autre s'exécutent au build seulement : scripts/check-assets.ts est la première étape de pnpm build (§12.9) et de pnpm check:guards ; il lit APP_ENV, appelle getContent() (garde G-00), puis assertProductionGuards et assertFilesystemGuards. Les gardes qui lisent le système de fichiers (registre content-validation.json pour G-05, fichiers d'image pour G-02, G-07, G-08, G-09) ne s'exécutent jamais au runtime du conteneur : l'image runner ne les contient pas, et getContent() y reste pur (analyse Zod et X-01…X-13 seulement). Les gardes de l'environnement relèvent de parseEnv / parseBuildEnv (§13.4), appelées au build par le layout. Les identifiants G-01 à G-10 sont ceux de §1.3.3 ; la présente section en détaille les règles de contenu et ajoute G-00 et G-11. G-04 s'exécute au démarrage du conteneur, pas au build ; il n'apparaît donc pas dans l'ordre d'impression ci-dessous.

Garde Condition de blocage Où Environnements bloquants
G-00 Erreur de schéma Zod ou violation X-01…X-13 (§9.3.3, §9.3.5) getContent Tous
G-01 Champ requis de legal.ts vide : publisher.legalName, legalForm, siret, address, email, phone, publicationDirector, host.name, host.address, host.phone, vatMention, privacy.retention, lastUpdated (date non future) ; legalName ≥ 2 caractères, address et vatMention ≥ 10 caractères ; si legalForm désigne une société (`/\b(SAS SASU SARL
G-02 Une chaîne de contenu ou un fichier d'image contient / référence dev-fallback (voir aussi ASSET_DEV_FALLBACK, §12.11.2) assertProductionGuards, check-assets.ts production
G-03 Témoignage published: true sans consentement granted, sans grantedOn valide non futur, ou sans citation (non vide, 700 caractères au plus) schéma (§9.3.3) Tous
G-04 Variable d'exécution requise absente ou invalide (BREVO_API_KEY, EMAIL_FROM_ADDRESS, LOG_SALT…) ; EMAIL_DRY_RUN=true en production ; domaine d'expédition appartenant à une messagerie grand public (§13.4) env.ts, exécuté au démarrage du serveur par src/instrumentation.ts production (démarrage, pas build)
G-05 Registre content-validation.json incomplet (§1.3.3), y compris la clé biography, ou moins de 2 paragraphes dans about.bio.paragraphs assertFilesystemGuards (lecture du registre via node:fs) production (avertissement ailleurs)
G-06, G-10 SITE_URL non conforme ; configuration Umami incomplète env.ts G-06 : production ; G-10 : tous
G-07, G-08, G-09 Fichiers d'image absents ou non conformes (format raster, dimensions minimales, ratio, canal alpha des calques), licences absentes, logo SVG dangereux check-assets.ts Voir §1.3.3 et §12.11
G-11 offers.offers vide (aucune offre) schéma Tous

Format exact du message (français ; une section par garde déclenchée ; une seule ligne finale ; code de sortie 1) :

✖ Garde {ID} — {intitulé de la garde} (APP_ENV={valeur})
  Fichier à mettre à jour : {chemin du fichier}          ← « Fichiers concernés : a, b » si plusieurs
  {Libellé de liste} :                                ← « Champs manquants », « Références interdites », « Violations »
    - {chemin du champ} {détail éventuel entre parenthèses}
    - …
… (autres gardes déclenchées, séparées par une ligne vide)
Le build est interrompu. Corrigez les champs ci-dessus puis relancez le build.

Exemples :

✖ Garde G-01 — informations légales incomplètes (APP_ENV=production)
  Fichier à mettre à jour : src/content/fr/legal.ts
  Champs manquants :
    - publisher.siret
    - publisher.address
    - host.name
    - vatMention

✖ Garde G-02 — éléments de développement présents (APP_ENV=production)
  Fichiers concernés : src/content/fr/hero.ts, src/content/fr/about.ts
  Références interdites :
    - hero › images.portrait.src (« /images/dev-fallback/hero-portrait.svg »)
    - about › portrait.src (« /images/dev-fallback/about-portrait.svg »)
Le build est interrompu. Corrigez les champs ci-dessus puis relancez le build.
✖ Garde G-00 — contenu invalide (APP_ENV=development)
  Fichiers concernés : src/content/fr/orientation.ts
  Violations :
    - orientation › situations.1.suggestedOfferId — offre inconnue « lecture-globale-2 » (X-04)
Le build est interrompu. Corrigez les champs ci-dessus puis relancez le build.

Règles du format : aucune valeur secrète n'est affichée ; chemins de champ en notation pointée ; ordre d'impression G-00, G-01, G-02, G-03, G-05, G-07, G-08, G-09, G-11 ; les avertissements (hors production) sont imprimés avec le préfixe ⚠ Avertissement {ID} — et n'interrompent pas le build.

9.3.8 Édition du contenu par Malka #

Il n'y a pas d'interface d'administration : le contenu s'édite dans les fichiers de src/content/fr/, puis le site est reconstruit. La rubrique « Modifier les contenus » du README.md (structure imposée par §18.9.1 ; tableau de référence en §18.9.2) détaille, avec un extrait avant/après, chacune des procédures suivantes :

Besoin Procédure
Changer un prix Dans offers.ts, modifier price.amountEur de l'offre ; l'affichage « 1 000 € » est calculé
Passer une offre « sur devis » Remplacer price par { kind: 'quote', label: 'Sur devis' }
Ajouter ou retirer une offre Ajouter/supprimer l'objet dans offers ; id (minuscules, tirets) et order uniques ; mettre à jour orientation.ts si une situation la suggère
Publier un témoignage testimonials.ts : citation mot pour mot, consent.status: 'granted', consent.grantedOn: 'AAAA-MM-JJ', consent.channel, published: true, et verificationNote renseignée
Retirer un témoignage published: false (la section disparaît d'elle-même s'il n'en reste aucun)
Mettre à jour les chiffres de confiance site.ts › trustFigures[*].value, puis nouvelle validation dans content-validation.json par Malka
Renseigner les mentions légales legal.ts (tous les champs requis) ; lastUpdated = date du jour
Remplacer une photo Déposer le fichier maître sous un nouveau nom suffixé -v2 dans public/images/<dossier>/, mettre à jour la référence (src, width, height, alt) dans le fichier de contenu et l'entrée du registre docs/assets-licences.json, puis lancer pnpm check:assets

Circuit de publication (aligné sur §12.12, §14 et §15) : modification sur une branche content/<sujet> → pull request → la CI exécute les gardes (le message d'erreur désigne fichier et champ, §9.3.7) → relecture → fusion → déploiement par l'opérateur (make deploy). Une erreur de contenu n'atteint jamais la production : le build échoue et l'ancienne version reste servie.

9.3.9 Prêt pour l'internationalisation #

  • Le contenu vit sous src/content/<locale>/ ; getContent(locale) est la seule porte d'entrée ; LOCALES = ['fr'] et DEFAULT_LOCALE = 'fr' sont les seules constantes à étendre.
  • <html lang> est dérivé de la locale (DEFAULT_LOCALE au lancement) ; les URL n'ont aucun préfixe de langue.
  • Les messages d'erreur de l'API (contact.apiErrors, contact.validation) sont dans le contenu, donc traduisibles sans toucher au code ; les gabarits d'e-mail (§9.5.3) sont isolés dans un module, remplaçable par locale.
  • Pour ajouter une langue plus tard : (1) ajouter la locale à LOCALES ; (2) dupliquer src/content/fr/ ; (3) enregistrer les imports dans RAW ; (4) introduire un segment [locale] dans src/app, un fichier proxy.ts pour la négociation, des alternates/hreflang et des entrées de sitemap par langue ; (5) passer la locale à getContent dans chaque composant serveur. Aucun changement de schéma n'est nécessaire.
  • Les identifiants (SECTION_IDS, METHOD_STEP_IDS, SITUATION_IDS, ids d'offres) ne sont jamais traduits : ils servent d'ancres, de clés et de valeurs d'API.

Critères d'acceptation 9.3

  • AC-9.3-01 — getContent('fr') parse sans exception les 13 fichiers livrés ; un test unitaire « chaque fichier livré est conforme à son schéma » (§15) parse en outre chaque fichier avec son schéma de §9.3.3 et vérifie qu'aucune clé n'est inconnue.
  • AC-9.3-02 — Une erreur de schéma dans un fichier (par exemple price.amountEur: -1) fait échouer pnpm build avec le format de §9.3.7 (garde G-00, fichier et chemin nommés).
  • AC-9.3-03 — Pour chacune des règles X-01…X-13, un test unitaire fournit une entrée invalide et vérifie le rejet.
  • AC-9.3-04 — Un suggestedOfferId inexistant est rejeté ; un offerId de témoignage inexistant est rejeté.
  • AC-9.3-05 — Un témoignage published: true avec consent.status: 'pending', sans grantedOn, avec grantedOn futur ou mal formé ('2026-02-30'), avec une citation vide (ou ne contenant que des espaces), ou deux témoignages de même id, est rejeté dans tous les environnements ; une citation de 1 à 700 caractères est acceptée.
  • AC-9.3-06 — Avec les deux témoignages livrés (published: false), getPublishableTestimonials retourne [], getRenderedSectionIds n'inclut pas temoignages, et le HTML de / ne contient ni id="temoignages" ni lien #temoignages.
  • AC-9.3-07 — APP_ENV=production avec legal.ts livré (valeurs vides) : le build échoue, G-01 liste chaque champ vide ; avec APP_ENV=development, le build réussit et les pages légales affichent le bandeau de développement.
  • AC-9.3-08 — APP_ENV=production avec une référence dev-fallback : G-02 échoue en nommant le fichier et le champ.
  • AC-9.3-09 — offers.offers: [] fait échouer le build (G-11) dans tous les environnements.
  • AC-9.3-10 — formatPrice({ kind:'fixed', amountEur:1000 }) retourne "1 000 €" avec U+202F et U+00A0 ; aucune sortie ne contient de décimales ; { kind:'quote', label:'Sur devis' } retourne "Sur devis".
  • AC-9.3-11 — Un lien interne externe (https://…, //hôte, javascript:) est rejeté par internalHref ; une chaîne contenant <script>, un caractère de contrôle ou U+202E est rejetée par text() et par textOrEmpty() (y compris dans testimonials.items[*].quote, testimonials.verificationNote, les champs de legal.ts et les alt d'image) ; la chaîne vide est acceptée par textOrEmpty() seulement.
  • AC-9.3-12 — Le message de chaque garde respecte exactement le gabarit de §9.3.7 (test de capture pour G-00, G-01, G-02, G-11).
  • AC-9.3-13 — site.contact : phoneDisplay, phoneE164 et whatsappE164Digits incohérents sont rejetés ; un SIRET de 14 chiffres avec une clé de Luhn fausse est rejeté ; trustFigures avec un id hors accompagnes/formes est rejeté.
  • AC-9.3-14 — orientation.situations[*].prompt d'une seule phrase ou de plus de quatre est rejeté ; questions à 1 ou 4 éléments est rejeté ; globalDisclaimer sans « diagnostic » est rejeté.
  • AC-9.3-15 — APP_ENV=production avec la clé biography du registre content-validation.json non validée, ou avec moins de 2 paragraphes dans about.bio.paragraphs, échoue (G-05) ; en development, un avertissement est imprimé sans interrompre le build ; aboutSchema ne contient ni bio.status ni bio.validatedOn.
  • AC-9.3-16 — La rubrique « Modifier les contenus » du README.md (§18.9.1) contient les 8 procédures du §9.3.8.
  • AC-9.3-17 — L'image runner démarrée avec APP_ENV=production, sans content-validation.json, sans docs/ et sans originaux, répond 200 à une soumission valide de POST /api/contact : aucune garde de production n'est exécutée au runtime et getContent ne lit aucun fichier.
  • AC-9.3-18 — legalSchema exige la clé privacy.retention ; APP_ENV=production avec privacy.retention vide échoue (G-01) ; aucune valeur par défaut n'est livrée.
  • AC-9.3-19 — PLACEHOLDER_PATTERN rejette [FIXTURE] et Auteur Fixture en APP_ENV=production (garde de qualité §9.3.6) et les accepte dans les jeux de test hors production.

9.4 Contrats d'API #

9.4.1 Vue d'ensemble #

Point d'accès Méthode Fichier Rôle
/api/contact POST src/app/api/contact/route.ts Reçoit, valide et transmet une demande de contact par e-mail
/api/health GET (et HEAD) src/app/api/health/route.ts Sonde de santé (Docker, Coolify, supervision)

Les deux fichiers exportent runtime = 'nodejs' et dynamic = 'force-dynamic'. Ils délèguent au module src/lib/contact/handler.ts (pipeline ; assistants dans src/lib/server/ : request-id.ts, client-ip.ts, origin-guard.ts, api-response.ts) ; le fichier de route ne contient que l'enrobage HTTP. Aucune route n'émet d'en-tête CORS : les requêtes inter-origines sont refusées par le navigateur (pas de réponse à OPTIONS, voir étape 1).

9.4.2 Schéma de la requête #

Le corps est un objet JSON (Content-Type: application/json, 16 Ko maximum). Le schéma est une fabrique partagée par le client et le serveur : les identifiants d'offres étant éditables (§9.3), la liste autorisée est passée en paramètre, ainsi que les messages d'erreur (contact.validation, §9.3.3), de sorte que le module reste isomorphe. Dans §6.9 et §15, contactRequestSchema désigne l'instance produite par cette fabrique (côté serveur dans src/lib/contact/handler.ts, côté client dans le formulaire, avec offerIds reçu en props) : aucun module n'exporte d'instance au niveau du module, et toute fabrique de schéma se nomme create<Nom>Schema.

// src/lib/contact/schema.ts  (module isomorphe — aucun import serveur)
import { z } from 'zod';
import { NON_DEFINED_OFFER, SITUATION_IDS, type ContactValidationMessages } from '@/lib/content/schemas';

export type ContactFieldKey =
  'name' | 'email' | 'phone' | 'offerId' | 'situationId' | 'message' | 'consent' | 'startedAt' | 'website';
export interface ContactSchemaOptions {
  offerIds: readonly string[];                 // ids de offers.offers
  messages: ContactValidationMessages;         // contact.validation — jamais la valeur saisie
}

const CONTROL = /[\u0000-\u001F\u007F\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/;   // contrôles, marques invisibles et bidirectionnelles (liste explicite de §9.3.2)
const emptyToUndefined = (v: unknown) => (typeof v === 'string' && v.trim() === '' ? undefined : v);

export function createContactRequestSchema({ offerIds, messages: m }: ContactSchemaOptions) {
  return z.object({
    name: z.string({ error: m.name.required }).trim()
      .min(1, { error: m.name.required }).min(2, { error: m.name.tooShort }).max(80, { error: m.name.tooLong })
      .refine((v) => !CONTROL.test(v), { error: m.name.invalid }),
    email: z.string({ error: m.email.required }).trim().toLowerCase()
      .min(1, { error: m.email.required }).max(254, { error: m.email.tooLong })
      .pipe(z.email({ error: m.email.invalid })),
    phone: z.preprocess(emptyToUndefined,
      z.string({ error: m.phone.invalid }).trim().max(20, { error: m.phone.tooLong })
        .regex(/^[0-9 +().-]{6,20}$/, { error: m.phone.invalid })
        .refine((v) => { const n = v.replace(/\D/g, '').length; return n >= 6 && n <= 15; }, { error: m.phone.invalid })
        .optional()),
    offerId: z.preprocess(emptyToUndefined,
      z.enum([...offerIds, NON_DEFINED_OFFER] as [string, ...string[]], { error: m.offerId.invalid }).optional()),
    situationId: z.preprocess(emptyToUndefined,
      z.enum(SITUATION_IDS, { error: m.situationId.invalid }).optional()),
    message: z.string({ error: m.message.required }).trim()
      .transform((v) => v.replace(/\r\n?/g, '\n'))
      .pipe(z.string()
        .min(1, { error: m.message.required }).min(20, { error: m.message.tooShort }).max(2000, { error: m.message.tooLong })
        .refine((v) => !/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/.test(v), { error: m.generic })
        .refine((v) => (v.match(/https?:\/\/|www\./gi) ?? []).length <= 2, { error: m.message.tooManyLinks })),
    consent: z.literal(true, { error: m.consent.required }),
    website: z.string({ error: m.generic }).max(200, { error: m.generic }),              // champ piège : présent, vide
    startedAt: z.int({ error: m.generic }).positive({ error: m.generic }),
  });
}
export type ContactRequest = z.output<ReturnType<typeof createContactRequestSchema>>;
export type ContactPayload = z.input<ReturnType<typeof createContactRequestSchema>>;

/** Première erreur de chaque champ → `{ champ: message }` (clé `_form` si le chemin est vide). */
export function toFieldErrors(error: z.ZodError): Record<string, string>;
Champ Type Obligatoire Règles (après trim) Messages (contact.validation.<champ>.<règle>)
name string oui 2 à 80 caractères ; aucun caractère de contrôle, saut de ligne, marque invisible ni marque bidirectionnelle (U+200B–200F, U+2028–2029, U+202A–202E, U+2066–2069, U+FEFF) required, tooShort, tooLong, invalid
email string oui Converti en minuscules ; adresse valide ; ≤ 254 caractères required, invalid, tooLong
phone string non '' traité comme absent ; motif ^[0-9 +().-]{6,20}$ ; entre 6 et 15 chiffres invalid, tooLong
offerId string non '' traité comme absent ; ∈ ids de offers.offers ∪ {non-defini} invalid
situationId string non '' traité comme absent ; ∈ {decision-bloquee, fatigue, repositionnement, transition} invalid
message string oui 20 à 2 000 caractères ; \r\n normalisé en \n ; pas de caractère de contrôle, de marque invisible ni de marque bidirectionnelle (mêmes listes que name, sauf \n et \t admis) ; au plus 2 liens (http://, https://, www.) required, tooShort, tooLong, tooManyLinks
consent booléen oui Littéral true (case non précochée, §6.9) required
website string oui (présent) Champ piège : doit être présent ; valeur non vide ⇒ abandon silencieux (étape 7) generic
startedAt entier oui Millisecondes epoch, > 0 (instant de première interaction avec le formulaire, recalé sur l'horloge serveur, §9.4.8) generic

Les clés inconnues sont ignorées (z.object les supprime). Les messages ne répètent jamais la valeur saisie. Le client affiche en priorité le message de la règle en échec ; le serveur ne renvoie que le premier message de chaque champ.

9.4.3 Pipeline de traitement (ordre normatif) #

Le handler initialise requestId = crypto.randomUUID() et t0 = performance.now() avant l'étape 1. Une seule ligne de journal contact.request est émise en fin de traitement (§9.7). Chaque étape qui échoue interrompt le pipeline.

# Étape Contrôle Échec → statut / code outcome journalisé
1 Méthode Seul POST est traité ; GET, PUT, PATCH, DELETE, OPTIONS sont exportés et répondent 405 avec Allow: POST 405 METHOD_NOT_ALLOWED method_not_allowed
2 Type de contenu Type média (insensible à la casse, paramètres ignorés) = application/json 415 UNSUPPORTED_MEDIA_TYPE unsupported_media_type
3 Origine Origin présent et égal à l'origine de SITE_URL (en development : accepte aussi http://localhost:3000 ; en development seulement, le journal contact.request ajoute originHost, hôte de l'Origin reçu sans schéma ni chemin, pour diagnostiquer un accès par 127.0.0.1 ou par l'adresse IP de la machine) ; si Sec-Fetch-Site est présent, il doit valoir same-origin. Contrôle peu coûteux placé avant toute lecture du corps : une requête d'origine inconnue ne fait jamais lire, décoder ni analyser son corps 403 FORBIDDEN_ORIGIN forbidden_origin
4 Taille Content-Length > 16 384 ⇒ rejet immédiat sans lecture ; sinon lecture du flux avec compteur d'octets, abandon dès 16 384 octets dépassés (requêtes sans Content-Length) 413 PAYLOAD_TOO_LARGE payload_too_large
5 Analyse JSON Décodage UTF-8 strict (TextDecoder avec fatal: true) puis JSON.parse ; corps vide, UTF-8 invalide, JSON invalide ou valeur racine qui n'est pas un objet ⇒ rejet. Le message d'erreur de l'analyseur n'est jamais journalisé ni renvoyé 400 INVALID_JSON invalid_json
6 Limitation de débit (consultation) IP cliente déterminée (§9.6.5) ; consommation d'une tentative (attempts_10m, 30 / 10 min) ; consultation sans consommation des fenêtres accepted_10m, accepted_24h et du coupe-circuit global_1h. Si plusieurs règles refusent, Retry-After est le maximum des délais des règles refusantes (§9.6.3) 429 RATE_LIMITED + Retry-After rate_limited
7 Honeypot et chronométrage Lecture des champs bruts : website est une chaîne non vide (même d'espaces) ⇒ spam ; startedAt est un nombre et Date.now() − startedAt < 3 000 (y compris futur) ou > 86 400 000 ⇒ spam. Spam ⇒ réponse 200 identique à un succès (nouvel requestId), aucun e-mail, aucune consommation des fenêtres « acceptées » 200 ok:true spam_dropped
8 Validation Zod createContactRequestSchema ; première erreur de chaque champ dans fields 422 VALIDATION_ERROR validation_failed
9 Réservation et déduplication (a) Empreinte SHA-256(ipKey + email + message normalisé) déjà vue depuis moins de 120 s ⇒ réponse 200 avec le requestId d'origine, sans envoi ; (b) consommation atomique des fenêtres accepted_10m, accepted_24h, global_1h (échec ⇒ 429) 200 / 429 duplicate_suppressed / rate_limited
10 Notification Envoi de l'e-mail à CONTACT_TO_EMAIL (§9.5). Échec ⇒ remboursement des créneaux réservés à l'étape 9 et suppression de l'empreinte de déduplication 502 EMAIL_DELIVERY_FAILED delivery_failed
11 Réponse 200 avec requestId, renvoyée dès que la notification est acceptée par le fournisseur 200 ok:true sent
12 Accusé de réception Exécuté après l'envoi de la réponse, par after() de next/server (Route Handler nodejs) ; n'allonge jamais le délai perçu par le visiteur. Conditions cumulatives : SEND_ACKNOWLEDGEMENT=true et les trois plafonds d'accusé non atteints (ack_email_24h : 2 / 24 h par adresse destinataire ; ack_ip_24h : 5 / 24 h par IP ; ack_global_1h : 30 / h au total, §9.6.2). Envoi unique (maxRetries: 0, délai 8 s), avec replyTo = site.contact.email (adresse publique de Malka, à laquelle le visiteur peut répondre). Échec ⇒ journal contact.ack outcome:"ack_failed" ; plafond atteint ⇒ outcome:"ack_suppressed" ; dans tous les cas la réponse 200 est déjà partie — —

Toute exception non prévue (hors étapes ci-dessus) est interceptée par un try/catch global : remboursement des créneaux éventuellement réservés, journal d'erreur (nom d'erreur uniquement, jamais de contenu utilisateur), réponse 500 INTERNAL_ERROR, outcome: internal_error.

Durée maximale théorique de la requête : notification 8 s + 0,5 s + 8 s, soit ≈ 16,5 s (l'accusé de l'étape 12 s'exécute après la réponse) ; le client abandonne à 30 s (§9.4.8).

9.4.4 Réponses #

Succès (statut 200) :

{ "ok": true, "data": { "requestId": "3f0c2c3e-5a73-4f2e-9d57-2b7a2f6f8a11" } }

Enveloppe d'erreur (tous les autres cas) :

{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Certaines informations sont manquantes ou invalides. Merci de vérifier les champs du formulaire.",
    "fields": { "email": "Veuillez indiquer une adresse e-mail valide." },
    "requestId": "3f0c2c3e-5a73-4f2e-9d57-2b7a2f6f8a11"
  }
}
// src/lib/contact/api-types.ts  (isomorphe)
import { API_ERROR_CODES } from '@/lib/content/schemas';
import type { ContactFieldKey } from '@/lib/contact/schema';
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
export type ApiSuccess<T> = { ok: true; data: T };
export type ApiFailure = {
  ok: false;
  error: { code: ApiErrorCode; message: string; fields?: Partial<Record<ContactFieldKey, string>>; requestId: string };
};
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
Code Statut En-têtes spécifiques Message utilisateur fields
METHOD_NOT_ALLOWED 405 Allow: POST contact.apiErrors.METHOD_NOT_ALLOWED non
UNSUPPORTED_MEDIA_TYPE 415 — contact.apiErrors.UNSUPPORTED_MEDIA_TYPE non
PAYLOAD_TOO_LARGE 413 — contact.apiErrors.PAYLOAD_TOO_LARGE non
INVALID_JSON 400 — contact.apiErrors.INVALID_JSON non
FORBIDDEN_ORIGIN 403 — contact.apiErrors.FORBIDDEN_ORIGIN non
RATE_LIMITED 429 Retry-After: <secondes entières ≥ 1> contact.apiErrors.RATE_LIMITED (le client ajoute la durée lisible calculée depuis Retry-After, §7.11.5) non
VALIDATION_ERROR 422 — contact.apiErrors.VALIDATION_ERROR oui
EMAIL_DELIVERY_FAILED 502 — contact.apiErrors.EMAIL_DELIVERY_FAILED non
INTERNAL_ERROR 500 — contact.apiErrors.INTERNAL_ERROR non

Les textes des messages sont ceux de §7.11.5, stockés dans contact.apiErrors (§9.3.3) et lus par le serveur au moment de construire l'enveloppe ; les clés supplémentaires OFFLINE, NETWORK_ERROR et TIMEOUT sont réservées au client (aucune réponse HTTP). Les messages de champ de fields sont ceux de contact.validation (§7.11.3). Les clés d'enveloppe et les codes sont normatifs ; la formulation appartient à §7.

En-têtes de toutes les réponses des routes API : Content-Type: application/json; charset=utf-8, Cache-Control: no-store, X-Request-Id: <requestId> ; plus les en-têtes de sécurité globaux de §10.3. Aucun Set-Cookie, aucun en-tête Access-Control-*.

9.4.5 Exemples #

Les noms, adresses et identifiants de ces exemples sont fictifs.

Requête valide :

{
  "name": "Camille Martin",
  "email": "camille.martin@example.com",
  "phone": "06 12 34 56 78",
  "offerId": "seance-clarification",
  "situationId": "decision-bloquee",
  "message": "Bonjour, je souhaite clarifier une décision que je repousse depuis plusieurs mois. Pouvons-nous échanger ?",
  "consent": true,
  "website": "",
  "startedAt": 1767268800000
}

Réponse 200 :

{ "ok": true, "data": { "requestId": "8d2b6c52-0a64-4b49-9a0b-6e3f1d9f1c20" } }

Réponse 422 (e-mail invalide, message trop court) :

{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Certaines informations sont manquantes ou invalides. Merci de vérifier les champs du formulaire.",
    "fields": {
      "email": "Veuillez indiquer une adresse e-mail valide.",
      "message": "Votre message doit contenir au moins 20 caractères."
    },
    "requestId": "1b7e0f4a-6a33-4c7e-8e74-2f3b9a5d0c11"
  }
}

Réponse 429 (en-tête Retry-After: 412) :

{
  "ok": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Vous avez envoyé plusieurs messages récemment. Merci de patienter quelques minutes avant de réessayer, ou d’écrire directement à Malka sur WhatsApp.",
    "requestId": "c4a1e9b2-3d58-4a6c-b2f1-0e7d8a9b6c33"
  }
}

Réponse 502 :

{
  "ok": false,
  "error": {
    "code": "EMAIL_DELIVERY_FAILED",
    "message": "Votre message n’a pas pu être envoyé pour le moment. Merci de réessayer dans quelques instants, ou d’écrire directement à Malka par WhatsApp ou par e-mail.",
    "requestId": "e90d4b77-1c2a-4f0e-8b5d-7a3c6f2e1d44"
  }
}

Réponse 403 (origine absente ou étrangère) :

{
  "ok": false,
  "error": {
    "code": "FORBIDDEN_ORIGIN",
    "message": "Une erreur technique empêche de recevoir votre message pour le moment. Merci de réessayer, ou d’écrire directement à Malka sur WhatsApp.",
    "requestId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"
  }
}

9.4.6 Idempotence et double soumission #

  • Le serveur ne reçoit pas de clé d'idempotence : la déduplication par contenu (étape 9a) fait office de garde. Deux requêtes identiques (même IP, même e-mail, même message normalisé) à moins de 120 s d'intervalle produisent un seul envoi ; la seconde reçoit 200 avec le requestId de la première et le journal outcome: duplicate_suppressed.
  • Le cache de déduplication est borné (500 entrées, durée de vie 120 s, nettoyage au même rythme que le limiteur, §9.6.4).
  • Le client désactive le bouton d'envoi dès le clic (état « envoi en cours ») et ne relance jamais automatiquement une requête (§9.4.8). Un délai dépassé côté client n'implique pas un échec côté serveur : si le visiteur renvoie, la déduplication évite le doublon.
  • Un échec 502 ne laisse aucune trace de déduplication ni de consommation de limite : le visiteur peut réessayer immédiatement. Les plafonds d'accusé de réception (étape 12) sont indépendants et ne sont jamais remboursés.

9.4.7 GET /api/health #

Réponse 200, sans appel externe, sans lecture de variable secrète :

{ "ok": true, "data": { "status": "ok", "version": "a1b2c3d", "uptimeSeconds": 4821 } }

version = APP_VERSION ; uptimeSeconds = Math.floor(process.uptime()). La version publiée est une divulgation acceptée, sans donnée personnelle (§10.2, T-18). En-têtes : Cache-Control: no-store, Content-Type: application/json; charset=utf-8, Date (utilisé par le client pour recaler son horloge, §9.4.8). HEAD renvoie les mêmes en-têtes sans corps ; c'est la méthode utilisée par calibrateClock() (§9.4.8). Les autres méthodes retournent l'enveloppe 405 METHOD_NOT_ALLOWED. Le processus est arrêté au démarrage si l'environnement est invalide (§13.4) ; la sonde échoue alors par absence de réponse, ce qui est le comportement voulu.

9.4.8 Contrat du client (src/lib/contact/submit.ts) #

export type SubmitOutcome =
  | { status: 'success'; requestId: string }
  | { status: 'validation'; code: 'VALIDATION_ERROR'; message: string; fields: Partial<Record<ContactFieldKey, string>> }
  | { status: 'rate_limited'; code: 'RATE_LIMITED'; message: string; retryAfterSeconds: number }
  | { status: 'delivery_failed'; code: 'EMAIL_DELIVERY_FAILED'; message: string }   // → afficher les alternatives WhatsApp / e-mail
  | { status: 'server_error'; code?: ApiErrorCode; httpStatus: number; message: string }   // `code` absent si la réponse n'est pas une enveloppe conforme
  | { status: 'network_error'; message: string }
  | { status: 'timeout'; message: string }
  | { status: 'aborted' };                                    // annulation par l'appelant : l'interface ne réagit pas

export interface SubmitOptions { signal?: AbortSignal; timeoutMs?: number /* 30_000 */; apiErrors: Content['contact']['apiErrors'] }
export function submitContact(payload: ContactPayload, options: SubmitOptions): Promise<SubmitOutcome>;

Règles du wrapper :

  1. fetch('/api/contact', { method:'POST', headers:{ 'Content-Type':'application/json', Accept:'application/json' }, body: JSON.stringify(payload), credentials:'same-origin', cache:'no-store', signal }). Aucun en-tête personnalisé, aucun jeton.

  2. Délai global de 30 s via AbortController (fusionné avec le signal de l'appelant) ; aucune relance automatique. Ce délai dépasse la durée maximale théorique du serveur (≈ 16,5 s, §9.4.3).

  3. Corps lu défensivement : si la réponse n'est pas du JSON conforme à ApiResponse (page HTML d'un proxy, par exemple), le résultat est server_error sans code, avec le message INTERNAL_ERROR.

  4. Correspondance : 200 + ok:true ⇒ success ; 422 ⇒ validation (avec fields) ; 429 ⇒ rate_limited (retryAfterSeconds lu dans Retry-After, 60 par défaut, borné à [1, 86 400]) ; 502 ⇒ delivery_failed ; 400, 403, 405, 413, 415, 500 et tout autre statut ⇒ server_error avec le code de l'enveloppe et le httpStatus ; TypeError de fetch ⇒ network_error ; abandon par le délai ⇒ timeout ; abandon par l'appelant ⇒ aborted.

  5. Les messages affichés proviennent de error.message du serveur ou, pour network_error et timeout, de contact.apiErrors.NETWORK_ERROR et contact.apiErrors.TIMEOUT ; l'état « hors connexion » (navigator.onLine === false) utilise contact.apiErrors.OFFLINE (§9.3.3).

  6. Dérivation du type d'échec affiché (table unique ; la présentation de chaque type est décrite en §6.9) :

    SubmitOutcome Type d'échec de l'interface
    validation validation
    rate_limited rate_limited
    delivery_failed delivery
    server_error + PAYLOAD_TOO_LARGE too_large
    server_error + FORBIDDEN_ORIGIN forbidden_origin
    server_error + INVALID_JSON, UNSUPPORTED_MEDIA_TYPE ou METHOD_NOT_ALLOWED technical
    server_error + INTERNAL_ERROR ou sans code internal
    network_error avec navigator.onLine === false offline
    network_error en ligne network
    timeout timeout
  7. Règles de startedAt (propriétaire : cette section). (a) Capture : à la première interaction (focusin ou input) avec le formulaire, startedAt = instant client de cette interaction + offsetMs (règle b) ; remis à null par « Envoyer un autre message ». (b) Recalage d'horloge : au montage du formulaire (après 1 s d'inactivité, setTimeout), calibrateClock() émet HEAD /api/health (délai 2 s) et calcule offsetMs = Date(en-tête serveur) − milieu(t_envoi, t_réception) ; la valeur est ignorée (0) si l'appel échoue ou si |offsetMs| > 86 400 000. Cela évite qu'une horloge de visiteur en avance de quelques secondes ne soit prise pour un robot (étape 7). (c) Remplissage rapide : si la soumission survient moins de 3 500 ms après startedAt (collage et remplissage automatique), l'envoi est retardé jusqu'à atteindre 3 500 ms, le bouton restant en état d'envoi ; le serveur tolère 3 000 ms, la marge de 500 ms couvre la dérive d'horloge. (d) Onglet resté ouvert : si l'âge de startedAt dépasse 20 h à la soumission, il est recalé sur maintenant + offsetMs − 10 000 ms, pour rester dans la fenêtre acceptée par le serveur (3 s à 24 h).

  8. Le wrapper ne journalise rien (ni console.log du payload, ni des champs).

Critères d'acceptation 9.4

  • AC-9.4-01 — Une requête valide produit 200 {ok:true,data:{requestId}} avec requestId UUID v4, l'en-tête X-Request-Id identique, et exactement un appel d'envoi de notification.
  • AC-9.4-02 — Les neuf codes d'erreur sont produits dans les conditions et avec les statuts du tableau de §9.4.4 (un test d'intégration par code).
  • AC-9.4-03 — L'ordre des étapes est respecté (méthode, type de contenu, origine, taille, JSON) : un corps de 20 Ko sans Origin renvoie 403 (le corps n'est pas lu) ; un JSON invalide sans Origin renvoie 403 ; avec un Origin valide, un corps de 20 Ko renvoie 413 et un JSON invalide renvoie 400.
  • AC-9.4-04 — Origin absent ⇒ 403 ; Origin: https://evil.example ⇒ 403 ; Sec-Fetch-Site: cross-site avec bon Origin ⇒ 403 ; Origin égal à SITE_URL ⇒ passe l'étape.
  • AC-9.4-05 — Honeypot non vide, startedAt à moins de 3 000 ms ou dans le futur, ou plus vieux de 24 h : réponse 200 ok:true, aucun appel à l'e-mail, journal outcome:"spam_dropped", et la réponse est indiscernable d'un succès (même forme).
  • AC-9.4-06 — Le 6ᵉ envoi valide d'une même IP en 10 minutes reçoit 429 avec Retry-After entier ≥ 1 ; les requêtes spam_dropped et validation_failed ne consomment pas les fenêtres « acceptées ».
  • AC-9.4-07 — Échec du fournisseur d'e-mail ⇒ 502 EMAIL_DELIVERY_FAILED et le créneau de limitation est remboursé (la requête suivante n'est pas pénalisée).
  • AC-9.4-08 — L'accusé de réception est envoyé après la réponse (after()) : la réponse 200 part sans attendre l'accusé, même si le fournisseur est lent ; un échec de l'accusé produit le journal contact.ack outcome:"ack_failed" (qui peut suivre contact.request) sans effet sur la réponse ; l'accusé porte replyTo = site.contact.email.
  • AC-9.4-09 — Deux requêtes identiques à 5 s d'intervalle ⇒ un seul envoi et le même requestId.
  • AC-9.4-10 — GET, PUT, DELETE, OPTIONS sur /api/contact ⇒ 405 avec Allow: POST et enveloppe JSON ; aucun en-tête Access-Control-* n'apparaît jamais.
  • AC-9.4-11 — Les messages d'erreur sont en français et n'incluent jamais la valeur saisie ; fields ne contient que des clés de ContactFieldKey (§9.4.2).
  • AC-9.4-12 — GET /api/health répond 200 en moins de 100 ms sans aucun appel réseau (test avec fetch interdit), avec Cache-Control: no-store.
  • AC-9.4-13 — Le wrapper client ne relance jamais ; un fetch suspendu au-delà de 30 s produit timeout ; une réponse HTML 502 d'un proxy produit server_error sans code ; un 413 produit server_error avec code: 'PAYLOAD_TOO_LARGE' et httpStatus: 413.
  • AC-9.4-14 — Un nom contenant \r\n ou U+202E, un e-mail contenant \n, un message de 3 liens, un consent valant "true" sont rejetés (422).

9.5 E-mail transactionnel #

9.5.1 Interface EmailProvider #

// src/lib/email/provider.ts
import 'server-only';

export interface EmailAddress { email: string; name?: string }

export interface EmailMessage {
  kind: 'contact_notification' | 'contact_acknowledgement';   // journalisation uniquement
  from: EmailAddress;
  to: [EmailAddress];                                         // un seul destinataire dans le MVP
  replyTo?: EmailAddress;                                     // notification : le visiteur ; accusé de réception : l'adresse publique de Malka (`site.contact.email`)
  subject: string;
  text: string;
  html: string;
  tags?: string[];
  headers?: Record<string, string>;
}

export interface SendOptions { requestId: string; timeoutMs?: number /* 8000 */; maxRetries?: number /* 1 */ }
export interface SendResult { messageId?: string }

export type EmailFailureKind = 'timeout' | 'network' | 'server' | 'rate_limited' | 'auth' | 'rejected' | 'unknown';
export class EmailSendError extends Error {
  constructor(public readonly kind: EmailFailureKind, public readonly attempts: number,
              public readonly providerStatus?: number, public readonly providerCode?: string) {
    super(`email_send_failed:${kind}`);   // le message ne contient jamais de donnée utilisateur
  }
}

export interface EmailProvider {
  readonly name: 'brevo' | 'dry-run';
  send(message: EmailMessage, options: SendOptions): Promise<SendResult>;   // rejette avec EmailSendError
}

Fabrique getEmailProvider(env) (src/lib/email/index.ts) : EMAIL_DRY_RUN vrai ⇒ DryRunEmailProvider ; sinon BrevoEmailProvider(env.BREVO_API_KEY). Un adaptateur Resend pourra être ajouté plus tard en implémentant la même interface et en étendant la fabrique ; il n'est pas livré dans le MVP.

9.5.2 Adaptateur Brevo #

Implémentation : src/lib/email/brevo.ts ; fournisseur à blanc : getEmailProvider retourne un DryRunEmailProvider défini dans src/lib/email/index.ts.

Élément Valeur
Point d'accès POST https://api.brevo.com/v3/smtp/email (constante du code, jamais configurable)
En-têtes api-key: <BREVO_API_KEY>, content-type: application/json, accept: application/json
Client HTTP fetch natif de Node (injectable dans le constructeur pour les tests), cache: 'no-store'
Délai 8 000 ms par tentative (AbortController)
Nouvelle tentative 1 seule, après 500 ms, uniquement sur erreur réseau, expiration du délai ou statut 5xx ; jamais sur 4xx. Une nouvelle tentative après expiration peut, dans de rares cas, produire un doublon dans la boîte de Malka : l'en-tête X-Request-Id de l'e-mail permet de le repérer
Succès Statut 2xx ; messageId lu dans la réponse s'il est présent (JSON invalide ignoré)
Clé API Passée uniquement par l'en-tête ; jamais dans l'URL, jamais journalisée

Charge utile de la notification (valeurs d'exemple fictives) :

{
  "sender": { "name": "Malka Debleds", "email": "contact@example.com" },
  "to": [{ "email": "astheoris.pro@gmail.com", "name": "Malka Debleds" }],
  "replyTo": { "email": "camille.martin@example.com", "name": "Camille Martin" },
  "subject": "Nouvelle demande — Séance de clarification ciblée — Camille Martin",
  "textContent": "…version texte brut…",
  "htmlContent": "<!doctype html>…version HTML simple, valeurs échappées…",
  "tags": ["site-contact"],
  "headers": { "X-Request-Id": "8d2b6c52-0a64-4b49-9a0b-6e3f1d9f1c20" }
}

Charge utile de l'accusé de réception : mêmes champs, replyTo = adresse publique de Malka (site.contact.email), to = [adresse du visiteur], tags: ["site-ack"], aucun contenu saisi par le visiteur.

Correspondance des erreurs (les détails restent dans les journaux ; le visiteur voit toujours le même 502) :

Situation EmailFailureKind Nouvelle tentative Niveau de journal Remarque d'exploitation
Expiration du délai (8 s) timeout oui (1) warn, puis error si échec final —
fetch lève une exception réseau network oui (1) idem —
5xx server oui (1) idem Incident côté fournisseur
429 rate_limited non error Quota Brevo atteint
401, 403 auth non error Clé invalide, révoquée, ou restriction d'IP du compte : procédure §10.10
400, 402, 404 et autres 4xx rejected non error Souvent expéditeur non vérifié (EMAIL_FROM_ADDRESS) : vérifier SPF/DKIM/DMARC et l'expéditeur dans Brevo
Autre unknown non error —

Chaque tentative émet email.send (outcome: ok, retry ou failed, avec provider, providerStatus, providerCode si présent et conforme à ^[a-z_]{3,40}$, attempt, durationMs). Le corps de réponse du fournisseur n'est jamais journalisé.

Pour l'accusé de réception, l'appel utilise { maxRetries: 0 } et s'exécute après la réponse au visiteur, par after() (étape 12 de §9.4.3).

9.5.3 Gabarits #

Les textes (sujet, corps, accusé de réception) sont ceux de §7.15 et vivent dans src/lib/email/templates.ts (module serveur, non éditable par Malka dans le MVP : un changement de texte d'e-mail se relit avec le code). Le présent paragraphe fixe la structure, l'interpolation, l'échappement et les interdits.

// src/lib/email/templates.ts
import 'server-only';

export interface NotificationInput {
  requestId: string; receivedAt: Date;
  name: string; email: string; phone?: string; message: string;
  offerTitle?: string;        // résolu côté serveur depuis offers.offers[offerId] — jamais depuis du texte saisi
  situationLabel?: string;    // résolu depuis orientation.situations[situationId]
}
export interface RenderedEmail { subject: string; text: string; html: string }

export function renderNotification(input: NotificationInput): RenderedEmail;
export function renderAcknowledgement(contact: SiteContact): RenderedEmail;   // contact = site.contact (§9.3.3)

/** Interpolation en UNE passe : les valeurs insérées ne sont jamais ré-interprétées (« {email} » saisi dans un message reste littéral). */
export const interpolate = (tpl: string, vars: Record<string, string>, encode: (s: string) => string = (s) => s): string =>
  tpl.replace(/\{([A-Za-z]+)\}/g, (m, k: string) => (Object.hasOwn(vars, k) ? encode(vars[k]!) : m));

Règles de la notification :

  • Gabarit de sujet et de texte : notificationEmail.subjectTemplate et textTemplate de §7.15.1, avec les jetons {offerTitleOrDefault}, {name}, {email}, {phoneOrNotProvided}, {offerTitleOrNotSpecified}, {situationLabelOrNotSpecified}, {message}, {date}, {time}, {requestId}. Les valeurs de repli (« Non renseigné », « Non précisée », « Prise de contact ») viennent de §7.15.1.
  • Sujet : Nouvelle demande — {offerTitleOrDefault} — {name} (tirets cadratins U+2014 entourés d'espaces). Le nom passe par toSingleLine(name, 80) ; le sujet complet est tronqué à 150 caractères.
  • Date et heure : Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris' }) ({date} = JJ/MM/AAAA, {time} = HH:mm).
  • Version HTML : mêmes champs, une ligne par champ dans un tableau simple ; toute valeur saisie passe par escapeHtml, le message par nl2br. Styles en ligne uniquement, largeur maximale 600 px, aucune image, aucune ressource externe, aucun pixel de suivi, aucun lien : e-mail et téléphone sont du texte brut (aucun mailto: ni tel: fabriqué à partir de la saisie). Poids < 100 Ko pour un message de 2 000 caractères.
  • replyTo = e-mail du visiteur (minuscules) avec le nom saisi nettoyé par toSingleLine.

Règles de l'accusé de réception : sujet et texte de §7.15.2, sans aucune interpolation de valeur saisie (ni nom, ni message, ni offre, ni situation, ni téléphone) ; coordonnées de contact (WhatsApp, téléphone, e-mail) lues dans site.contact ; HTML = paragraphes issus du texte brut échappé ; aucun lien suivi ; replyTo = site.contact.email (adresse publique de Malka, jamais une adresse saisie).

Échappement (module src/lib/email/escape.ts) :

const MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
export const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => MAP[c]!);
export const nl2br = (s: string): string => escapeHtml(s).replace(/\r?\n/g, '<br>');   // échapper d'abord, puis insérer les <br>
export const toSingleLine = (s: string, max: number): string =>
  s.replace(/[\u0000-\u001F\u007F\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);

Toute valeur utilisateur insérée dans le HTML passe par escapeHtml ou nl2br (pas de double décodage : a &amp; b devient a &amp;amp; b). Le texte brut contient les valeurs littérales, avec sauts de ligne conservés.

9.5.4 Mode « à blanc » (EMAIL_DRY_RUN) #

DryRunEmailProvider n'effectue aucun appel réseau, journalise email.dry_run avec uniquement : kind, domaine du destinataire (jamais l'adresse), longueurs du sujet, du texte et du HTML, requestId, et retourne { messageId: 'dry-run-<requestId>' }. Le corps, le sujet et les adresses ne sont jamais écrits, même en niveau debug. Valeurs par défaut et interdiction en production : voir §13.

9.5.5 Prérequis d'exploitation #

  • Domaine d'envoi authentifié dans Brevo (SPF, DKIM, DMARC), EMAIL_FROM_ADDRESS sur ce domaine ; une adresse de messagerie grand public est refusée en production par la validation d'environnement (§13.4).
  • Dans le compte Brevo : désactiver le suivi des ouvertures et des clics pour les e-mails transactionnels (aucune réécriture de liens, aucun pixel) et choisir la durée de conservation des journaux la plus courte disponible (§10.6.7).
  • Accepter l'accord de traitement des données (DPA) de Brevo (§10.6.6).
  • Si le compte Brevo restreint les adresses IP autorisées, y ajouter l'adresse publique du serveur (ou désactiver la restriction) ; un changement de serveur impose de la mettre à jour (symptôme : EMAIL_DELIVERY_FAILED avec providerStatus:401).

Critères d'acceptation 9.5

  • AC-9.5-01 — Le test de la charge utile Brevo contient sender, to, subject, htmlContent, textContent ; replyTo vaut l'adresse du visiteur pour la notification et l'adresse publique de Malka (site.contact.email) pour l'accusé ; l'en-tête api-key est présent et absent de toute sortie de journal.
  • AC-9.5-02 — Sur 503 puis 201, l'adaptateur fait exactement 2 appels espacés d'au moins 500 ms ; sur 400, exactement 1 appel ; sur 401, 1 appel et kind: 'auth'.
  • AC-9.5-03 — Un appel qui ne répond pas est abandonné à 8 s (horloge simulée) et produit kind: 'timeout' après la nouvelle tentative.
  • AC-9.5-04 — Pour name = '<script>alert(1)</script>', le HTML ne contient aucune balise <script et contient &lt;script&gt; ; le texte brut contient la valeur littérale.
  • AC-9.5-05 — Pour name = 'Camille\r\nBcc: x@example.invalid' transmis directement au gabarit (hors validation), le sujet ne contient ni \r ni \n.
  • AC-9.5-06 — L'accusé de réception ne contient aucune valeur « canari » saisie (nom, message, téléphone, offre, situation) dans le sujet, le HTML ou le texte.
  • AC-9.5-07 — En mode à blanc, fetch n'est jamais appelé et le journal ne contient ni sujet ni adresse complète.
  • AC-9.5-08 — Aucune adresse ou valeur saisie ne figure dans un attribut href ou src du HTML généré.
  • AC-9.5-09 — interpolate('{constructor} {name}', { name: 'Camille' }) retourne {constructor} Camille : seul un jeton présent parmi les propres clés des valeurs est remplacé (Object.hasOwn).

9.6 Limitation de débit et anti-spam #

9.6.1 Défenses en couches #

Couche Mécanisme Étape de §9.4.3 Effet
Origine Origin obligatoire, Sec-Fetch-Site ; contrôlée avant la lecture du corps 3 403
Plafond de tentatives 30 requêtes / 10 min / IP, toutes issues confondues 6 429
Honeypot Champ website invisible 7 Abandon silencieux
Chronométrage Délai minimal 3 s, validité 24 h 7 Abandon silencieux
Validation stricte Zod, limite de liens, caractères interdits 8 422
Limites d'acceptation 5 / 10 min et 20 / 24 h par IP ; coupe-circuit global 200 / h 9 429
Déduplication Empreinte de contenu, 120 s 9 200 sans envoi
Limites de l'accusé 2 accusés / 24 h par adresse destinataire, 5 / 24 h par IP, 30 / h au total 12 Accusé non envoyé (la réponse 200 est déjà partie)
Interrupteur SEND_ACKNOWLEDGEMENT=false 12 Aucun accusé (mesure d'urgence, §10.10)

Aucun CAPTCHA tiers : pas de pistage, pas de script externe, pas de friction pour le visiteur.

9.6.2 Règles et valeurs #

// src/lib/rate-limit/config.ts
export interface RateLimitRule { name: string; limit: number; windowMs: number }
const MIN = 60_000, HOUR = 3_600_000, DAY = 86_400_000;
export const CONTACT_LIMITS = {
  attemptsPerIp:     { name: 'attempts_10m',  limit: 30,  windowMs: 10 * MIN },
  acceptedPerIp10m:  { name: 'accepted_10m',  limit: 5,   windowMs: 10 * MIN },
  acceptedPerIp24h:  { name: 'accepted_24h',  limit: 20,  windowMs: DAY },
  globalPerHour:     { name: 'global_1h',     limit: 200, windowMs: HOUR },
  ackPerEmail24h:    { name: 'ack_email_24h', limit: 2,   windowMs: DAY },
  ackPerIp24h:       { name: 'ack_ip_24h',    limit: 5,   windowMs: DAY },     // plafonds d'accusé : limitent l'usage du site comme relais vers des adresses tierces
  ackGlobalPerHour:  { name: 'ack_global_1h', limit: 30,  windowMs: HOUR },
} as const satisfies Record<string, RateLimitRule>;
export const SPAM_MIN_FILL_MS = 3_000;
export const SPAM_MAX_AGE_MS = DAY;
export const DEDUPE_TTL_MS = 120_000;
export const DEDUPE_MAX_ENTRIES = 500;

9.6.3 Algorithme : fenêtre glissante par journal d'horodatages #

Pour chaque couple (clé, règle), le limiteur conserve un tableau trié d'horodatages (ms) des événements acceptés dans la fenêtre.

consume(checks, now):                       // checks = liste de { key, rule }, tout-ou-rien
  refus = []
  pour chaque check :                       // toutes les règles sont évaluées, aucune sortie anticipée
      arr = store.get(key|rule.name) ?? []
      retirer de arr tout t <= now - rule.windowMs
      si arr.length >= rule.limit :
          refus.push({ rule: rule.name, retryAfterSeconds: max(1, ceil((arr[0] + rule.windowMs - now) / 1000)) })
  si refus non vide :
      pire = la règle de refus dont retryAfterSeconds est le plus grand
      retourner { allowed:false, rule:pire.rule, retryAfterSeconds:pire.retryAfterSeconds }
  (aucune violation) pour chaque check : arr.push(now)
  retourner { allowed:true, ticket:{ now, entries:[key|rule.name…] } }

peek(checks, now)  : même évaluation, sans push
refund(ticket)     : retirer une occurrence de ticket.now dans chaque tableau de ticket.entries

Propriétés : le tableau ne dépasse jamais rule.limit éléments (on n'ajoute pas au-delà), la consommation est atomique (aucune fenêtre n'est incrémentée si une autre refuse), et l'évaluation d'un appel est synchrone dans le fil Node, ce qui exclut les conditions de concurrence entre peek et consume à l'intérieur d'un même appel. Le Retry-After renvoyé est celui de la règle la plus contraignante : le maximum des délais de toutes les règles refusantes, et rule désigne la règle qui porte ce maximum.

9.6.4 Bornes mémoire et nettoyage #

Paramètre Valeur Justification
Entrées maximales par limiteur 10 000 couples (clé, règle) ≈ 2 à 3 Mo au pire ; le coupe-circuit global borne de toute façon l'abus
Éviction LRU : à chaque accès, l'entrée est réinsérée en fin de Map ; au-delà du maximum, la plus ancienne est supprimée Un attaquant à très nombreuses IP ne peut pas faire croître la mémoire
Balayage périodique setInterval(sweep, 60_000) avec .unref() Supprime les tableaux dont le dernier horodatage + fenêtre ≤ now
Balayage opportuniste À chaque appel, si now − dernierBalayage ≥ 60 000 ms Garantit la purge même si le minuteur ne tourne pas (tests à horloge simulée)
Cache de déduplication 500 entrées, 120 s Même balayage
Singleton Stocké sur globalThis (Symbol.for('malka.rateLimiter')) Survit au rechargement à chaud et aux doubles chargements de module
Méthode de diagnostic size(): number (nombre de clés retenues) Réservée aux tests

9.6.5 Clés et adresse IP cliente #

  • getClientIp(request, trustProxy) : si TRUST_PROXY=true, prend la dernière valeur de X-Forwarded-For (rognée) — celle que le proxy de confiance a écrite ou ajoutée ; les valeurs précédentes sont fournies par le client et ignorées ; elle doit être une adresse IPv4 ou IPv6 valide (net.isIP), sinon la clé unknown est utilisée ; si TRUST_PROXY=false, l'en-tête est ignoré et la clé unknown est utilisée (Next.js n'expose pas l'adresse de connexion aux Route Handlers). Les adresses IPv4 mappées (::ffff:a.b.c.d) sont ramenées à IPv4 ; une adresse IPv6 est ramenée à son préfixe /64.
  • Clé du limiteur : ipKey = SHA-256(ip + LOG_SALT) en hexadécimal complet (jamais l'adresse en clair, ce qui évite de la répandre dans un futur magasin partagé). Journaux : ipHash = ipKey.slice(0, 12).
  • Fichiers : extraction de l'IP dans src/lib/server/client-ip.ts, implémentation en mémoire dans src/lib/rate-limit/memory-sliding-window.ts, fabrique getRateLimiter() dans src/lib/rate-limit/index.ts.
  • Clés : attempts:<ipKey>, ip:<ipKey>, global, ack:<SHA-256(email + LOG_SALT)>, ackip:<ipKey>, ackglobal.
  • Hypothèse d'exploitation : un seul proxy de confiance (Caddy, proxy de Coolify) se trouve devant le conteneur et écrit ou ajoute l'adresse réelle du client en dernière position de X-Forwarded-For. Avec un proxy qui réécrit l'en-tête, il n'y a qu'une valeur ; avec un proxy qui ajoute, la dernière valeur reste fiable. Derrière un service qui masque l'adresse du visiteur (réseau de diffusion de contenu), tous les visiteurs partagent le seau de l'adresse de ce service. Si le conteneur est exposé sans proxy, TRUST_PROXY doit valoir false (tous les visiteurs partagent alors le seau unknown).

9.6.6 Interface RateLimiter (remplaçable par Redis) #

// src/lib/rate-limit/types.ts
export interface RateLimitCheck { key: string; rule: RateLimitRule }
export interface RateLimitTicket { now: number; entries: string[] }
export type RateLimitDecision =
  | { allowed: true; ticket: RateLimitTicket }
  | { allowed: false; rule: string; retryAfterSeconds: number };

export interface RateLimiter {
  peek(checks: RateLimitCheck[], now?: number): Promise<RateLimitDecision>;     // sans effet
  consume(checks: RateLimitCheck[], now?: number): Promise<RateLimitDecision>;  // atomique, tout-ou-rien
  refund(ticket: RateLimitTicket): Promise<void>;
  size(): number;                                                              // diagnostic de test
}
export const getRateLimiter = (): RateLimiter => /* singleton MemoryRateLimiter */;

L'interface est asynchrone pour qu'un adaptateur Redis (script Lua exécutant la même logique atomique) remplace MemoryRateLimiter sans modifier le pipeline. Cet adaptateur n'est pas livré dans le MVP.

9.6.7 Honeypot, chronométrage et heuristiques #

  • Honeypot : le champ website est un <input type="text"> hors flux visuel (positionné hors écran, jamais display:none seul), tabindex="-1", autocomplete="off", aria-hidden="true" sur son conteneur, libellé contact.fields.website.label (§9.3.3, §6.9). Toute valeur non vide, y compris faite d'espaces, ⇒ spam.
  • Chronométrage : Date.now() − startedAt. Strictement inférieur à 3 000 (valeurs négatives comprises) ⇒ spam ; exactement 3 000 ⇒ accepté ; exactement 86 400 000 ⇒ accepté ; supérieur ⇒ spam.
  • Pas de journal du contenu d'une soumission jugée spam (seulement outcome:"spam_dropped" et, le cas échéant, ipHash).
  • Surveillance : le ratio spam_dropped / total est lisible dans les journaux (§9.7) ; un ratio > 50 % sur 7 jours justifie une revue des protections.

9.6.8 Limites connues #

  • Instance unique : l'état est en mémoire du processus. Un redémarrage remet les compteurs à zéro ; deux instances doubleraient les plafonds. Le déploiement du MVP exécute exactement un conteneur web (§14) ; l'ajout de réplicas exige l'adaptateur Redis de §9.6.6.
  • Un visiteur légitime derrière une adresse partagée (entreprise, réseau mobile) partage son seau ; les plafonds (5 envois / 10 min) restent très au-dessus d'un usage humain normal.
  • Sans TRUST_PROXY, tous les visiteurs partagent le même seau : réglage réservé au développement local.
  • Le plafond attempts_10m (30 / 10 min / IP) compte chaque requête POST qui atteint l'étape 6, quelle que soit son issue : des tests de bout en bout qui partagent une même IP doivent rester sous ce plafond ou espacer leurs envois.

Critères d'acceptation 9.6

  • AC-9.6-01 — 5 consume pour une même IP en 10 min sont acceptés, le 6ᵉ est refusé avec retryAfterSeconds = ceil((t₁ + 10 min − now)/1000).
  • AC-9.6-02 — Après 10 min + 1 s, le 6ᵉ appel est accepté (fenêtre glissante) ; avec appels à 0, 2, 4, 6, 8 min puis avance à 10 min + 1 s, un seul créneau est libéré.
  • AC-9.6-03 — 20 acceptations étalées sur 24 h puis une 21ᵉ ⇒ refus calculé sur la fenêtre de 24 h.
  • AC-9.6-04 — Le coupe-circuit refuse la 201ᵉ acceptation en une heure, toutes IP confondues ; il se rétablit après 1 h + 1 s.
  • AC-9.6-05 — consume est atomique : un refus sur global_1h n'incrémente pas accepted_10m ; refund restitue les créneaux.
  • AC-9.6-06 — size() ne dépasse jamais 10 000 ; après 24 h + 1 s d'inactivité suivies d'un appel, size() ne contient que les clés actives.
  • AC-9.6-07 — TRUST_PROXY=true avec X-Forwarded-For: 198.51.100.9, 203.0.113.7 ⇒ IP 203.0.113.7 (dernière entrée) ; avec X-Forwarded-For: 203.0.113.7 ⇒ 203.0.113.7 ; valeur malformée ("", "not-an-ip") ⇒ unknown ; TRUST_PROXY=false ⇒ en-tête ignoré.
  • AC-9.6-08 — Les journaux de refus contiennent ipHash (12 caractères hexadécimaux) et jamais l'adresse en clair ni l'adresse e-mail.
  • AC-9.6-09 — Heuristiques : honeypot 'x' et ' ' ⇒ spam ; délai 2 999 ms ⇒ spam ; 3 000 ms ⇒ accepté ; futur (+5 000 ms) ⇒ spam ; 86 400 000 ms ⇒ accepté ; 86 400 001 ms ⇒ spam.
  • AC-9.6-10 — Le troisième accusé en 24 h vers la même adresse, le sixième en 24 h depuis la même IP et le 31ᵉ en 1 h au total ne sont pas envoyés (ack_suppressed), sans impact sur la réponse 200.
  • AC-9.6-11 — Lorsque accepted_10m (libération dans 3 min) et accepted_24h (libération dans 6 h) refusent simultanément, la décision porte rule: 'accepted_24h' et retryAfterSeconds = 21 600 : Retry-After est le maximum des règles refusantes.

9.7 Journalisation et gestion des erreurs #

9.7.1 API du logger #

// src/lib/log/logger.ts
import 'server-only';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';

/** Liste blanche : toute clé absente de cette interface est supprimée avant écriture. */
export interface LogFields {
  requestId?: string; outcome?: string; code?: string; durationMs?: number; status?: number;
  ipHash?: string; rule?: string; retryAfterSeconds?: number;
  provider?: 'brevo' | 'dry-run'; providerStatus?: number; providerCode?: string; attempt?: number; kind?: string;
  toDomain?: string; subjectLength?: number; textLength?: number; htmlLength?: number;
  route?: string; routeType?: string; method?: string; digest?: string;
  errName?: string; errMessage?: string; version?: string; appEnv?: string; dryRun?: boolean; originHost?: string;
  analytics?: string; acknowledgement?: boolean; missing?: string[];
}
export const logger: {
  debug(event: string, fields?: LogFields): void;
  info(event: string, fields?: LogFields): void;
  warn(event: string, fields?: LogFields): void;
  error(event: string, fields?: LogFields): void;
};
export const hashIp: (ip: string, salt: string) => string;     // 12 premiers caractères hex de SHA-256(ip + salt)
export const scrub: (s: string) => string;                    // retire adresses e-mail et suites de 6 chiffres ou plus, tronque à 200 caractères
  • Sortie : une ligne JSON par événement sur la sortie standard (process.stdout.write(JSON.stringify(entry) + '\n')), jamais plusieurs lignes ; format { ts, level, event, requestId?, outcome?, code?, durationMs?, … } avec ts en ISO 8601 UTC.
  • Seuil : LOG_LEVEL (debug < info < warn < error), défaut info.
  • Les clés hors liste blanche sont supprimées ; en test (NODE_ENV=test) elles lèvent une erreur pour détecter l'oubli.
  • errMessage n'est renseigné que pour des erreurs internes, après scrub() : suppression de tout motif d'adresse e-mail ([^\s@]+@[^\s@]+ ⇒ [email]), de toute suite de 6 chiffres ou plus (⇒ [num]), troncature à 200 caractères. Il n'est jamais renseigné pour une erreur d'analyse JSON, de validation Zod ou de réponse du fournisseur.

9.7.2 Ce qui n'est jamais journalisé #

Corps de requête, nom, adresse e-mail, téléphone, message, sujet ou corps d'e-mail, adresse IP complète, valeur de BREVO_API_KEY ou LOG_SALT, en-têtes de requête (Cookie, Authorization, Origin complet), corps des réponses du fournisseur, messages d'erreur d'analyse JSON. Les journaux ne sont jamais envoyés hors de l'hôte.

9.7.3 Catalogue des événements #

Événement Niveau Champs Émis par
app.start info version, appEnv, dryRun, analytics, acknowledgement instrumentation.ts, au démarrage
app.env_invalid error missing (noms de variables uniquement) instrumentation.ts, avant process.exit(1)
contact.request info (warn pour delivery_failed, error pour internal_error) requestId, outcome, status, code?, durationMs, ipHash? Fin du pipeline §9.4.3
contact.ack info / warn requestId, outcome ∈ {ack_sent, ack_failed, ack_suppressed}, durationMs Étape 12 (après la réponse ; peut suivre contact.request)
ratelimit.blocked warn requestId, rule, ipHash, retryAfterSeconds Étapes 6 et 9
email.send info / warn / error requestId, provider, kind, outcome ∈ {ok, retry, failed}, providerStatus?, providerCode?, attempt, durationMs Adaptateur e-mail
email.dry_run info requestId, kind, toDomain, subjectLength, textLength, htmlLength Fournisseur à blanc
server.error error route, routeType, method, digest, errName, errMessage? onRequestError

outcome de contact.request ∈ {sent, spam_dropped, duplicate_suppressed, validation_failed, invalid_json, unsupported_media_type, payload_too_large, forbidden_origin, rate_limited, method_not_allowed, delivery_failed, internal_error}. GET /api/health n'émet aucun journal (bruit des sondes).

Exemple :

{"ts":"2026-03-04T10:15:32.481Z","level":"info","event":"contact.request","requestId":"8d2b6c52-0a64-4b49-9a0b-6e3f1d9f1c20","outcome":"sent","status":200,"durationMs":742,"ipHash":"9f2c41a7be03"}

9.7.4 Instrumentation Next.js #

// src/instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME !== 'nodejs') return;
  if (process.env.NEXT_PHASE === 'phase-production-build') return;     // pas de validation d'exécution pendant le build
  const { loadEnvOrExit } = await import('./lib/env');
  const { logger } = await import('./lib/log/logger');
  const env = loadEnvOrExit();                                          // journalise app.env_invalid puis process.exit(1) si invalide
  logger.info('app.start', { version: env.APP_VERSION, appEnv: env.APP_ENV, dryRun: env.EMAIL_DRY_RUN,
                             analytics: env.ANALYTICS_PROVIDER, acknowledgement: env.SEND_ACKNOWLEDGEMENT });
}

export async function onRequestError(
  error: { digest?: string } & Error,
  request: { path: string; method: string },
  context: { routerKind: string; routePath: string; routeType: string },
) {
  if (process.env.NEXT_RUNTIME !== 'nodejs') return;
  const { logger, scrub } = await import('./lib/log/logger');
  logger.error('server.error', {
    route: context.routePath, routeType: context.routeType, method: request.method,   // `request.path` (peut contenir une requête) n'est pas journalisé
    digest: error.digest, errName: error.name, errMessage: scrub(error.message),
  });
}

9.7.5 Gestion d'erreurs côté client #

  • SectionErrorBoundary (classe React, getDerivedStateFromError) enveloppe chaque îlot interactif des sections (MethodCompass, OrientationCompass, TestimonialsSlider, ContactForm, HeroLandscape, FooterRiver). Son repli (fallback) est statique et utile : pour ContactForm, les liens WhatsApp, téléphone et e-mail de site.contact ; pour les autres, le contenu textuel sans animation. L'erreur est affichée dans la console en développement uniquement ; rien n'est envoyé au serveur.
  • error.tsx (segment) et global-error.tsx (racine, qui rend ses propres <html lang="fr"> et <body>, sans dépendre des feuilles de style globales) : page spécifiée en §6.11, avec bouton « Réessayer » (reset()) et lien vers l'accueil. La référence error.digest est affichée pour permettre à l'exploitant de retrouver l'événement server.error correspondant.
  • Les rejets de promesses dans les gestionnaires d'événements (hors rendu) sont capturés localement (try/catch) : un échec de track() ou d'animation ne doit jamais empêcher l'action du visiteur.

Critères d'acceptation 9.7

  • AC-9.7-01 — Chaque ligne de journal est un JSON valide d'une seule ligne contenant ts, level, event.
  • AC-9.7-02 — Après 20 requêtes de contact variées (succès, erreurs, spam), aucune ligne de journal ne contient le nom, l'e-mail, le téléphone, le message de test (valeurs « canari »), ni une adresse IP complète.
  • AC-9.7-03 — Un champ hors liste blanche passé à logger.info lève une erreur en test et est supprimé en production.
  • AC-9.7-04 — LOG_LEVEL=warn supprime les lignes info.
  • AC-9.7-05 — Une exception volontaire dans un composant serveur produit un événement server.error avec digest et sans message contenant un motif d'e-mail.
  • AC-9.7-06 — Une exception levée dans OrientationCompass affiche le repli sans casser le reste de la page ; une exception dans ContactForm affiche les liens WhatsApp, téléphone et e-mail.
  • AC-9.7-07 — Au démarrage avec un environnement de production invalide, app.env_invalid est émis (noms des variables, jamais leurs valeurs) et le processus se termine avec le code 1.

9.8 Analytique : adaptateur et catalogue d'événements #

9.8.1 Principes #

ANALYTICS_PROVIDER vaut none (défaut) ou umami. Aucun autre fournisseur n'est admis par la configuration (§10.6.4). Avec none, aucun script n'est chargé et track() est sans effet (CtaLink reste un simple lien). Le fournisseur est fixé au build (variables de build, §13).

9.8.2 Script Umami #

Composant serveur UmamiScript (src/lib/analytics/umami-script.tsx, import 'server-only') : ne rend rien si le fournisseur est none. Avec umami :

<Script
  src={env.UMAMI_SCRIPT_URL}
  strategy="afterInteractive"
  defer
  data-website-id={env.UMAMI_WEBSITE_ID}
  data-do-not-track="true"
  data-auto-track="true"
  data-exclude-search="true"
  data-exclude-hash="true"
/>

Attributs figés dans le code (non configurables) : data-do-not-track="true" (le navigateur qui exprime « Ne pas suivre » n'est pas mesuré), data-exclude-search="true" et data-exclude-hash="true" (l'URL mesurée ne contient ni paramètre de requête ni ancre : le préremplissage ?offre= et les ancres de section n'atteignent jamais l'outil de mesure), aucun data-domains, aucun data-tag, aucun data-host-url. L'origine de UMAMI_SCRIPT_URL est ajoutée à la CSP (script-src et connect-src uniquement, §10.3). L'instance recommandée est auto-hébergée : le script et la réception des événements partagent la même origine ; une instance dont la réception est sur une autre origine n'est pas prise en charge sans extension du générateur de CSP.

9.8.3 Catalogue d'événements (propriétaire : cette section) #

// src/lib/analytics/events.ts  (isomorphe)
export const CTA_IDS = [
  'nav_faire_le_point', 'hero_rdv', 'hero_methode', 'offer_whatsapp', 'offer_form',
  'orientation_contact', 'footer_cta', 'contact_whatsapp',
] as const;
export type CtaId = (typeof CTA_IDS)[number];

export type AnalyticsEvent =
  | { name: 'cta_click'; props: { cta: CtaId; offer_id?: string } }
  | { name: 'contact_submit'; props: { result: 'success' | 'error' } }
  | { name: 'method_step_open'; props: { step: MethodStepId } };
Événement Déclencheur Propriétés Notes
cta_click Clic sur un CtaLink cta (liste fermée ci-dessus) ; offer_id uniquement pour offer_whatsapp et offer_form Émis par CtaLink (onClick, avant l'ouverture du lien)
contact_submit Réponse finale du formulaire result : success si SubmitOutcome.status === 'success', error pour tout autre résultat final (hors aborted) Émis par ContactForm, un seul événement par tentative terminée
method_step_open Ouverture d'une étape de la Boussole de clarté par l'utilisateur (clic, touche, toucher, ancre #methode-<étape>) step ∈ ecouter, explorer, clarifier, decider Pas d'événement pour l'aperçu au survol, ni pour l'état initial

Correspondance cta ↔ élément : nav_faire_le_point (CTA de navigation, barre desktop et menu mobile), hero_rdv (« Prendre rendez-vous »), hero_methode (« Découvrir la méthode »), offer_whatsapp (lien WhatsApp d'une carte d'offre, avec offer_id), offer_form (lien « Écrire via le formulaire » d'une offre, avec offer_id), orientation_contact (les deux CTA du panneau de la boussole, sans offer_id ni situation, §6.6.9), footer_cta (carte flottante du footer), contact_whatsapp (bouton WhatsApp de la section contact). Un clic dans le menu mobile et dans la navigation desktop produit la même valeur.

Interdits absolus : l'identifiant de situation de la boussole (et tout offer_id sur orientation_contact, car le couple situation → offre le révélerait), tout contenu de formulaire, toute donnée à caractère personnel, tout identifiant persistant. Aucun événement n'est émis pour la sélection d'une situation, les témoignages, ni le défilement.

// src/lib/analytics/index.ts  (module client sûr, aucun import serveur)
import { CTA_IDS, type AnalyticsEvent } from './events';

declare global { interface Window { umami?: { track: (name: string, data?: Record<string, string | number | boolean>) => void } } }

const ALLOWED_PROPS: Record<AnalyticsEvent['name'], readonly string[]> = {
  cta_click: ['cta', 'offer_id'], contact_submit: ['result'], method_step_open: ['step'],
};

export function track<E extends AnalyticsEvent>(name: E['name'], props: E['props']): void {
  try {
    const allowed = ALLOWED_PROPS[name];
    if (!allowed) return;                                              // événement hors catalogue : ignoré
    if (typeof window === 'undefined' || typeof window.umami?.track !== 'function') return;   // fournisseur none ou script bloqué
    const clean: Record<string, string> = {};
    for (const key of allowed) {
      const v = (props as Record<string, unknown>)[key];
      if (typeof v === 'string' && v.length > 0 && v.length <= 60) clean[key] = v;
    }
    if (name === 'cta_click' && !CTA_IDS.includes(clean.cta as never)) return;
    if (name === 'cta_click' && clean.cta !== 'offer_whatsapp' && clean.cta !== 'offer_form') delete clean.offer_id;
    window.umami.track(name, clean);
  } catch { /* l'analytique ne doit jamais perturber l'interface */ }
}

Avec ANALYTICS_PROVIDER=none, le script n'est pas chargé : window.umami n'existe pas et track() retourne sans effet (aucun fetch, aucune écriture de cookie ou de stockage, aucune exception). Les propriétés hors liste (situationId, email, message…) sont retirées avant envoi.

CtaLink (src/components/ui/cta-link.tsx, 'use client' ; seul composant de lien mesuré) :

interface CtaLinkProps extends React.ComponentProps<'a'> {
  cta: CtaId;                 // identifiant de §9.8.3
  offerId?: string;           // transmis seulement pour offer_whatsapp et offer_form
}

Il rend un <a> (ou next/link pour un lien interne), appelle track('cta_click', { cta, offer_id }) dans son onClick puis laisse la navigation se poursuivre (aucun preventDefault) ; il ne construit aucune URL (le href lui est fourni, §9.9).

Critères d'acceptation 9.8

  • AC-9.8-01 — Avec ANALYTICS_PROVIDER=none : aucun <script> d'analytique dans le HTML, aucune requête vers une origine tierce, track() sans effet.
  • AC-9.8-02 — Avec umami : le HTML contient un seul <script defer> avec data-website-id, data-do-not-track="true", data-auto-track="true", data-exclude-search="true", data-exclude-hash="true" et la source UMAMI_SCRIPT_URL.
  • AC-9.8-03 — Un clic sur chaque CtaLink appelle window.umami.track('cta_click', …) avec exactement les propriétés du tableau ; offer_id n'est présent que pour offer_whatsapp et offer_form.
  • AC-9.8-04 — track('cta_click', { cta:'orientation_contact', situationId:'fatigue', offer_id:'lecture-globale' }) n'envoie ni situationId ni offer_id, et aucun paramètre d'URL d'orientation n'est transmis à l'outil de mesure.
  • AC-9.8-05 — contact_submit n'envoie que result ; method_step_open n'envoie que step (valeur hors des 4 identifiants rejetée).
  • AC-9.8-06 — Un événement hors catalogue est ignoré ; window.umami indéfini ne lève aucune exception.
  • AC-9.8-07 — Un clic sur un CtaLink n'empêche jamais l'ouverture du lien, même si track() lève une exception.
  • AC-9.8-08 — Avec umami (instance simulée) et un chargement de /?offre=lecture-globale#contact, aucune requête envoyée à l'instance Umami ne contient lecture-globale, offre ni #contact ; l'URL de la page après chargement ne contient plus le paramètre offre (§6.5.4).

9.9 Point d'extension de réservation #

9.9.1 Contrat du MVP #

// src/lib/booking/types.ts  (le constructeur d'URL vit dans whatsapp.ts, la fonction publique dans index.ts)
export type BookingProvider = 'whatsapp';                 // seule valeur au lancement

export interface WhatsAppBookingAction {
  kind: 'whatsapp';
  href: string;                                           // https://wa.me/<digits>?text=<encodeURIComponent(message)>
  label: string;
}
export type BookingAction = WhatsAppBookingAction;        // union élargie lors de l'ajout d'un fournisseur

export interface BookingInput { offerId?: string; situationId?: string }
// src/lib/booking/index.ts  (import 'server-only' : charge le contenu ; jamais importé par un îlot client)
export function getBookingAction(input: BookingInput = {}, content: Content = getContent('fr')): BookingAction;
// src/lib/booking/whatsapp.ts  (isomorphe)
export function buildWhatsAppUrl(digits: string, message: string): string;   // seule fonction autorisée à produire une URL wa.me

Résolution du message (premier cas applicable) : (1) situationId valide ⇒ orientation.situations[…].whatsappMessage ; (2) offerId valide ⇒ offers.offers[…].whatsappMessage ; (3) sinon ⇒ contact.whatsappCard.defaultMessage. Une valeur inconnue n'est jamais reflétée ni ne lève d'exception : elle retombe sur le cas suivant. Le libellé provient du contenu selon l'appelant (offers.ctaLabels.whatsapp, orientation.ctaLabels.whatsapp, contact.whatsappCard.ctaLabel, ou le libellé de la carte du footer) ; le texte masqué « (ouvre WhatsApp dans un nouvel onglet) » vient du suffixe correspondant (…whatsappNewTabSuffix / ctaNewTabSuffix). Le numéro vient de site.contact.whatsappE164Digits (chiffres uniquement, ^\d{8,15}$ vérifié). L'encodage utilise exclusivement encodeURIComponent (jamais encodeURI ni remplacement manuel).

Règles : les composants ne construisent jamais d'URL ni de message WhatsApp ; ils reçoivent un BookingAction calculé par un composant serveur (getBookingAction s'exécute au build ; il charge le contenu par défaut, porte import 'server-only' et n'est donc jamais importé par un îlot client), puis le rendent dans un lien target="_blank" rel="noopener noreferrer" avec le texte masqué du suffixe de nouvel onglet. Un îlot client qui a besoin d'un lien WhatsApp (boussole d'orientation, formulaire, carte du footer) le reçoit sous forme de href en props, calculé par le composant serveur de sa section (par exemple un dictionnaire situationId → { href, label } pour la boussole) : aucun îlot n'importe src/lib/booking/index.ts, src/lib/content/** ni src/content/**. Aucune requête, aucun script, aucun SDK WhatsApp.

9.9.2 Ajout ultérieur de Cal.com (sans refonte) #

Étapes à exécuter le jour où Malka décide d'ajouter la prise de rendez-vous en ligne ; aucune n'est faite dans le MVP :

  1. Fournisseur : ajouter 'calcom' à BookingProvider ; ajouter CalcomBookingAction { kind:'calcom'; href: string; label: string; mode: 'link' | 'embed' } à l'union BookingAction.
  2. Configuration : ajouter la variable de build CALCOM_LINK (URL https de la page de réservation) au schéma d'environnement et au tableau de §13 ; sa présence active le fournisseur (getBookingProvider(env)).
  3. Fonction : étendre getBookingAction par un switch (provider) ; ajouter getBookingActions(input): { primary: BookingAction; secondary?: BookingAction } (Cal.com en principal, WhatsApp en secondaire) — l'API existante reste valide.
  4. Emplacement d'interface : la section Contact (§6.9) ne rend aucun emplacement au MVP ; l'extension ajoutera, entre le bloc WhatsApp et le formulaire, un îlot client src/components/booking/calcom-embed.tsx chargé à la demande après un clic. Un lien sortant simple (mode:'link', nouvel onglet) est la variante par défaut : il ne modifie ni la CSP ni la politique de confidentialité autrement que par la mention du service tiers.
  5. Sécurité et conformité : pour mode:'embed', ajouter l'origine de Cal.com à script-src, frame-src et connect-src via le générateur de CSP (§10.3), documenter ce sous-traitant dans la politique de confidentialité (legal.ts et §10.6.6) et vérifier si l'intégration dépose des traceurs : si oui, un mécanisme de consentement devient obligatoire (hors périmètre du MVP) et la variante link doit être retenue.
  6. Analytique : ajouter offer_calcom (et contact_calcom) à CTA_IDS (§9.8.3), sans propriété supplémentaire.
  7. Tests : étendre les tests de §15 (action retournée, absence de requête avant clic, CSP).
  8. Rien d'autre ne change : les cartes d'offre, la boussole et le footer continuent d'appeler getBookingAction ; seul le contenu du bouton (le BookingAction reçu) évolue.

Critères d'acceptation 9.9

  • AC-9.9-01 — Pour chaque offre, getBookingAction({ offerId }) retourne { kind:'whatsapp', href, label } avec href égal à https://wa.me/33667295179?text=${encodeURIComponent(offer.whatsappMessage)} calculé indépendamment (le numéro vient de site.contact.whatsappE164Digits).
  • AC-9.9-02 — getBookingAction({ situationId }) utilise le message de la situation ; getBookingAction({ offerId: 'inconnu' }) et getBookingAction({}) retournent l'action générique sans exception ni reflet de la valeur reçue.
  • AC-9.9-03 — Aucun composant de src/components/** ne contient la chaîne wa.me (test de recherche textuelle).
  • AC-9.9-04 — La partie numéro de l'URL ne contient ni +, ni espace, ni tiret ; aucun appel à encodeURI dans src/lib/booking/ (règle ESLint).

10. Sécurité, RGPD et conformité #

Cette section est la référence unique pour la sécurité applicative, la protection des données personnelles et les obligations légales françaises du site. Elle ne constitue pas un avis juridique : les textes publiés (mentions légales, politique de confidentialité) sont validés par Malka, qui reste responsable de traitement et éditrice du site.

10.1 Principes #

  1. Moins de données, moins de risques : aucune base de données, aucun cookie, aucun traceur, aucun script tiers par défaut ; le seul traitement de données personnelles est le formulaire de contact (§10.6).
  2. Défense en profondeur : chaque risque est traité par au moins deux couches indépendantes (validation, limitation, en-têtes, isolation du conteneur).
  3. Échec fermé en production : une configuration manquante ou non conforme empêche le build ou le démarrage (§9.3.7, §13.4) au lieu de dégrader silencieusement la sécurité.
  4. Aucun secret côté client : les secrets n'existent que dans l'environnement d'exécution du conteneur et dans les modules serveur (§9.2.4).

10.2 Modèle de menaces #

ID Menace Vecteur Mitigations (couches) Référence
T-01 Robots de spam sur le formulaire POST automatisés, remplissage massif Honeypot, délai minimal de 3 s, validité 24 h, limite de liens, plafonds par IP et coupe-circuit global, déduplication, pas de CAPTCHA tiers §9.6, §9.4.3
T-02 Bombardement d'e-mails via l'accusé de réception ; atteinte à la réputation de l'expéditeur Saisie de l'adresse d'une victime, répétée ou multipliée sur de nombreuses adresses Accusé à texte fixe sans contenu saisi, envoyé après la réponse (after()), plafonds propres à l'accusé : 2 / 24 h par adresse, 5 / 24 h par IP, 30 / h au total ; plafonds d'acceptation par IP et coupe-circuit global ; interrupteur SEND_ACKNOWLEDGEMENT=false §9.4.3 (étape 12), §9.6.2, §10.10
T-03 Injection d'en-têtes e-mail \r\n dans le nom, l'e-mail ou le sujet Rejet des caractères de contrôle par Zod ; toSingleLine sur le sujet ; l'API Brevo reçoit du JSON (pas de SMTP brut) ; replyTo validé comme adresse §9.4.2, §9.5.3
T-04 XSS par le contenu de l'e-mail HTML <script> ou attribut dans le nom ou le message Échappement systématique (escapeHtml, nl2br), aucune valeur saisie dans un attribut ni un lien, aucune ressource externe, texte brut en alternative §9.5.3
T-05 Publication inter-origines / CSRF Page tierce qui poste vers /api/contact Origin obligatoire et strictement égal à SITE_URL, Sec-Fetch-Site: same-origin, aucun en-tête CORS, Content-Type: application/json (non simple ⇒ pré-requête refusée), aucun cookie d'authentification à abuser §9.4.3 (étape 3)
T-06 Déni de service sur l'API Corps volumineux, rafales Limite de 16 Ko avec lecture bornée, plafond de 30 tentatives / 10 min / IP, mémoire du limiteur bornée (10 000 clés, LRU), délais d'attente fournisseur 8 s, read_only et limites du conteneur §9.4.3, §9.6.4, §14
T-07 Fuite de secrets Clé Brevo dans le dépôt, l'image, le bundle client ou les journaux Variables d'environnement d'exécution uniquement, .dockerignore, aucun ARG secret, aucun NEXT_PUBLIC_*, liste blanche du logger, test d'absence dans .next/static, contrôle de motifs dans le dépôt §10.4, §9.7, §9.2.4
T-08 Chaîne d'approvisionnement Paquet npm compromis, image de base obsolète Lockfile commité et --frozen-lockfile, pnpm audit bloquant en CI, scripts d'installation interdits par défaut, dépendances minimales et liste d'exclusion, reconstruction régulière avec --pull §9.1, §10.5
T-09 Détournement d'interface (clickjacking) Page tierce qui encadre le site X-Frame-Options: DENY et frame-ancestors 'none' §10.3
T-10 Redirection ouverte via paramètres d'URL ?offre=…, ancres Aucune redirection n'est lue depuis l'URL ; seul offre est lu, côté client, une seule fois au montage du formulaire, validé contre la liste fermée des offres (valeur inconnue ignorée), puis retiré de l'URL (history.replaceState) ; la situation de la boussole ne figure dans aucune URL ; aucune valeur d'URL n'est utilisée dans un href, un src ou du HTML ; les href du contenu sont des liens internes (internalHref) §9.3.2, §6.5.4, §6.9.7
T-11 Abus du préremplissage WhatsApp Message modifié pour tromper ou nuire Messages définis dans le contenu (jamais construits depuis l'URL ni la saisie), longueur ≤ 300, aucun lien dans le message (X-12), seul src/lib/booking/ produit l'URL, numéro issu de site.ts §9.9, §9.3.5
T-12 Usurpation de l'expéditeur Faux e-mails au nom du domaine Domaine d'envoi authentifié (SPF, DKIM, DMARC), adresse d'expédition sur ce domaine, visiteur uniquement en replyTo §9.5.5
T-13 Falsification de l'adresse IP (limitation contournée) X-Forwarded-For forgé Prise en compte seulement si TRUST_PROXY=true ; seule la dernière entrée de l'en-tête (celle du proxy de confiance) est retenue, les entrées fournies par le client sont ignorées ; valeur validée comme IP ; coupe-circuit global indépendant de l'IP §9.6.5
T-14 Injection de journaux / fuite de données personnelles dans les journaux Saisies contenant des retours à la ligne ou données privées Aucune saisie utilisateur journalisée, liste blanche de champs, une ligne JSON par événement §9.7
T-15 Divulgation d'information par les erreurs Messages d'erreur détaillés, traces Messages utilisateur génériques en français, détails uniquement dans les journaux sans contenu utilisateur, pas de source maps publiques, X-Powered-By supprimé §9.4.4, §10.3
T-16 Cookies et pistage involontaires Script tiers, police distante, intégration Aucun cookie, aucun stockage local (hors opposition explicite à l'analytique), polices auto-hébergées, aucun embed, requêtes tierces interdites par la CSP et testées §10.6.3, §10.3
T-17 Rejeu d'une requête valide Répétition de la même soumission Déduplication de contenu 120 s, plafonds de débit §9.4.6
T-18 Divulgation de la version déployée GET /api/health publie version et uptimeSeconds Risque accepté : aucune donnée personnelle, aucun secret ; la version sert au contrôle de déploiement et la réponse n'est pas mise en cache §9.4.7

10.3 En-têtes HTTP de sécurité #

10.3.1 Valeurs #

Appliqués à toutes les routes (pages, ressources statiques et API) par la fonction headers() de next.config.ts, qui n'écrit aucune règle elle-même et appelle l'unique constructeur pur buildHeaderRules de src/lib/security/headers.ts (§10.3.3). Le proxy (Caddy ou proxy de Coolify) n'ajoute ni ne redéfinit aucun en-tête.

En-tête Valeur exacte (production)
Content-Security-Policy Voir §10.3.2
Strict-Transport-Security Production : max-age=31536000; includeSubDomains (pas de preload) ; préproduction (staging) : max-age=300, sans includeSubDomains ; développement : absent
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()
X-Frame-Options DENY
Cross-Origin-Opener-Policy same-origin

Décisions : Strict-Transport-Security n'est émis que si SITE_URL commence par https:// ; la durée d'un an avec includeSubDomains est réservée à la production (un sous-domaine mal configuré resterait inaccessible un an pour les navigateurs qui ont reçu l'en-tête) et impose que tous les sous-domaines du domaine servent HTTPS ; la préproduction émet une durée de 300 s sans includeSubDomains ; l'option preload n'est pas activée (inscription irréversible à la liste de préchargement : à décider par Malka plus tard, hors MVP). Aucun Cross-Origin-Embedder-Policy ni Cross-Origin-Resource-Policy n'est défini (inutiles ici et susceptibles de gêner les aperçus de partage). poweredByHeader: false supprime X-Powered-By.

10.3.2 Content-Security-Policy #

Production, sans analytique :

default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'none'; frame-src 'none'; manifest-src 'self'; worker-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests

Avec ANALYTICS_PROVIDER=umami : l'origine de UMAMI_SCRIPT_URL (schéma + hôte + port, sans chemin) est ajoutée à script-src et à connect-src uniquement.

Directive Valeur Justification
default-src 'self' Refus par défaut de toute origine tierce
script-src 'self' 'unsafe-inline' (+ origine Umami) Les pages sont statiques : Next.js y insère des scripts d'amorçage en ligne qu'un nonce par requête ne peut pas couvrir. 'unsafe-eval' est interdit en production. Le risque résiduel est borné : aucun contenu utilisateur n'est rendu dans les pages, tout texte est échappé par React, dangerouslySetInnerHTML est interdit par ESLint (seule exception : le bloc JSON-LD sérialisé de <JsonLd>, §11). Cette concession est la seule ; toute évolution (HTML injecté, contenu utilisateur rendu) impose de passer à des empreintes SRI ou à un nonce par requête (rendu dynamique)
style-src 'self' 'unsafe-inline' Styles en ligne produits par Motion et React
img-src 'self' data: Images optimisées locales et substituts flous en data:
font-src 'self' Polices auto-hébergées au build
connect-src 'self' (+ origine Umami) fetch('/api/contact'), HEAD /api/health (calibration d'horloge, §9.4.8)
frame-src, object-src 'none' Aucun embed
form-action, base-uri 'self' Empêche la redirection de formulaires et le détournement de <base>
frame-ancestors 'none' Anti-encadrement (avec X-Frame-Options)
upgrade-insecure-requests présent si SITE_URL est en https Absent en développement HTTP local

En development : script-src ajoute 'unsafe-eval' (rafraîchissement à chaud) et connect-src ajoute ws://localhost:3000 ws://127.0.0.1:3000 ; upgrade-insecure-requests et HSTS sont omis. Les liens sortants (WhatsApp) ne sont pas régis par la CSP.

10.3.3 Implémentation #

// src/lib/security/headers.ts  (module pur et isomorphe, testable ; sans import de env.ts ni lecture de process.env)
export interface SecurityHeaderEnv {
  appEnv: 'development' | 'staging' | 'production';
  siteUrl: string;
  analytics: 'none' | 'umami';
  umamiScriptUrl?: string;
}
export interface HeaderEntry { key: string; value: string }
export interface HeaderRule { source: string; headers: HeaderEntry[] }

/** Lecture tolérante de l'environnement de build (la validation stricte est faite par parseEnv, §13.4, pendant le build). */
export function readSecurityEnv(src: Record<string, string | undefined>): SecurityHeaderEnv {
  const appEnv = src.APP_ENV === 'production' || src.APP_ENV === 'staging' ? src.APP_ENV : 'development';
  return {
    appEnv,
    siteUrl: src.SITE_URL ?? 'http://localhost:3000',
    analytics: src.ANALYTICS_PROVIDER === 'umami' && !!src.UMAMI_SCRIPT_URL ? 'umami' : 'none',
    umamiScriptUrl: src.UMAMI_SCRIPT_URL || undefined,
  };
}

export function buildCsp(env: SecurityHeaderEnv): string {
  const dev = env.appEnv === 'development';
  const analyticsOrigin = env.analytics === 'umami' && env.umamiScriptUrl ? new URL(env.umamiScriptUrl).origin : null;
  const list = (...v: (string | null | false)[]) => v.filter(Boolean).join(' ');
  const directives: string[] = [
    "default-src 'self'",
    `script-src ${list("'self'", "'unsafe-inline'", dev && "'unsafe-eval'", analyticsOrigin)}`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data:",
    "font-src 'self'",
    `connect-src ${list("'self'", analyticsOrigin, dev && 'ws://localhost:3000 ws://127.0.0.1:3000')}`,
    "media-src 'self'", "object-src 'none'", "frame-src 'none'", "manifest-src 'self'", "worker-src 'self'",
    "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'",
  ];
  if (env.siteUrl.startsWith('https://')) directives.push('upgrade-insecure-requests');
  return directives.join('; ');
}

/** HSTS : production = un an avec sous-domaines ; préproduction = 300 s sans sous-domaines ; développement = absent ; absent aussi si `SITE_URL` n'est pas en https. */
export function buildHsts(env: SecurityHeaderEnv): string | null {
  if (!env.siteUrl.startsWith('https://') || env.appEnv === 'development') return null;
  return env.appEnv === 'production' ? 'max-age=31536000; includeSubDomains' : 'max-age=300';
}

export function buildSecurityHeaders(env: SecurityHeaderEnv): HeaderEntry[] {
  const h: HeaderEntry[] = [
    { key: 'Content-Security-Policy', value: buildCsp(env) },
    { key: 'X-Content-Type-Options', value: 'nosniff' },
    { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
    { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()' },
    { key: 'X-Frame-Options', value: 'DENY' },
    { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
  ];
  const hsts = buildHsts(env);
  if (hsts) h.push({ key: 'Strict-Transport-Security', value: hsts });
  return h;
}

/** Source unique de toutes les règles d'en-têtes HTTP de l'application : sécurité (§10.3), `X-Robots-Tag` (§11.1.7) et `Cache-Control` (§11.3.8). */
export function buildHeaderRules(env: SecurityHeaderEnv): HeaderRule[] {
  const immutable: HeaderEntry[] = [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }];
  const rules: HeaderRule[] = [{ source: '/:path*', headers: buildSecurityHeaders(env) }];
  rules.push(env.appEnv === 'production'
    ? { source: '/api/:path*', headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }] }
    : { source: '/:path*', headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow, noarchive' }] });   // hors production : toutes les routes, API comprise
  rules.push(
    { source: '/images/:path*', headers: immutable },
    { source: '/textures/:path*', headers: immutable },
    { source: '/og/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=86400, stale-while-revalidate=604800' }] },
    { source: '/sitemap.xml', headers: [{ key: 'Cache-Control', value: 'public, max-age=3600' }] },
    { source: '/robots.txt', headers: [{ key: 'Cache-Control', value: 'public, max-age=3600' }] },
  );
  return rules;
}
// next.config.ts (extrait ; imports relatifs, sans alias « @/ » ; la lecture directe de process.env est autorisée dans ce fichier, §12.1)
import type { NextConfig } from 'next';
import { buildHeaderRules, readSecurityEnv } from './src/lib/security/headers';

const config: NextConfig = {
  output: 'standalone',
  poweredByHeader: false,
  productionBrowserSourceMaps: false,
  async headers() {
    return buildHeaderRules(readSecurityEnv(process.env));   // aucune règle d'en-tête n'est écrite ailleurs
  },
};
export default config;

Les en-têtes sont figés au build (variables de build, §13) ; next.config.ts ne dépend pas de src/lib/env.ts (marqué server-only, §12.3.2) : changer le fournisseur d'analytique ou SITE_URL impose une reconstruction. Le proxy inverse (Caddy ou proxy de Coolify) n'ajoute ni ne modifie aucun en-tête : il assure le TLS et la compression ; le cache du HTML est celui par défaut de Next.js.

Critères d'acceptation 10.3

  • AC-10.3-01 — curl -I sur /, /mentions-legales, /api/health et un fichier statique renvoie les sept en-têtes de §10.3.1 avec les valeurs exactes (HSTS seulement avec SITE_URL en https : max-age=31536000; includeSubDomains en production, max-age=300 en préproduction).
  • AC-10.3-02 — Sans analytique, la CSP ne contient aucune origine hors 'self' ; avec Umami, l'origine du script apparaît dans script-src et connect-src uniquement.
  • AC-10.3-03 — La CSP de production ne contient pas 'unsafe-eval' ; celle de développement le contient.
  • AC-10.3-04 — Un parcours Playwright complet (toutes sections, formulaire en succès simulé, menu mobile) n'émet aucune violation CSP (securitypolicyviolation écouté, 0 occurrence) et aucune requête hors origine (hors Umami si activé).
  • AC-10.3-05 — Le site ne se charge pas dans un <iframe> d'une autre origine (test d'intégration).
  • AC-10.3-06 — Lighthouse « Bonnes pratiques » ≥ 95 sans avertissement de sécurité (§11).
  • AC-10.3-07 — La CSP de production ne contient ni 'unsafe-eval', ni *, ni schéma http: ; buildHeaderRules est l'unique producteur de règles d'en-têtes (aucun headers: dans next.config.ts hors de l'appel au constructeur, aucun header dans le Caddyfile).

10.4 Gestion des secrets #

Règle Détail
Secrets existants BREVO_API_KEY, LOG_SALT (§13)
Emplacement Variables d'environnement d'exécution du conteneur (fichier .env du serveur chargé par env_file, ou variables « runtime » de Coolify) ; jamais dans le dépôt, jamais en ARG/ENV du Dockerfile, jamais dans une couche d'image
Fichier d'exemple .env.example commité, sans valeur pour les secrets (contenu en §13.5) ; .env, .env.* (sauf .env.example) dans .gitignore et .dockerignore
Build Seules les variables non secrètes de build (§13) sont transmises en --build-arg ; elles sont visibles dans docker history et sont donc publiques par nature
Client Aucune variable NEXT_PUBLIC_* ; les valeurs de build nécessaires au navigateur sont passées en props par des composants serveur (§9.2.3)
Journaux Jamais de valeur secrète (§9.7.2) ; les erreurs de validation d'environnement nomment les variables sans afficher leurs valeurs
Contrôles automatiques (1) Test d'absence de motifs interdits dans .next/static (§9.2.4) ; (2) en CI, git grep -nE "xkeysib-[A-Za-z0-9-]{20,}" sur l'arbre suivi doit ne rien trouver ; (3) test que .dockerignore contient .env* et .git
Partage Un secret n'est jamais collé dans une conversation, un ticket, un message ou une capture d'écran ; transmission par gestionnaire de mots de passe
Rotation Clé Brevo : au moins une fois par an et à tout soupçon de fuite (procédure §10.10) ; LOG_SALT : lors de la même opération
Droits minimaux La clé Brevo est dédiée à ce site (nom explicite), avec la seule capacité d'envoi transactionnel ; si le compte le permet, restreindre les adresses IP autorisées à celle du VPS (un changement de serveur impose de la mettre à jour)

Critères d'acceptation 10.4

  • AC-10.4-01 — git ls-files ne contient aucun fichier .env (hors .env.example) ; .env.example ne contient aucune valeur pour BREVO_API_KEY et LOG_SALT.
  • AC-10.4-02 — docker history de l'image finale ne montre ni BREVO_API_KEY ni LOG_SALT.
  • AC-10.4-03 — docker run de l'image sans BREVO_API_KEY avec APP_ENV=production s'arrête avec un message nommant la variable (sans valeur).
  • AC-10.4-04 — Le contrôle git grep des motifs de clé échoue la CI s'il trouve une occurrence.

10.5 Hygiène des dépendances #

Mesure Mise en œuvre
Lockfile pnpm-lock.yaml commité ; installation pnpm install --frozen-lockfile en CI et dans le Dockerfile ; toute mise à jour passe par une pull request relue
Audit en CI pnpm audit --prod --audit-level=high : une vulnérabilité de gravité haute ou critique sur une dépendance de production fait échouer la CI ; les gravités inférieures sont listées sans bloquer
Scripts d'installation Exécution des scripts de dépendances désactivée par défaut (comportement actuel de pnpm) ; liste blanche initiale limitée à sharp (et à esbuild si pnpm l'exige, §9.1) ; toute entrée ajoutée est justifiée dans DECISIONS.md
Mises à jour automatiques Pas de Renovate ni d'équivalent (décision : un seul mainteneur, volume faible). Remplacé par une vérification hebdomadaire manuelle, documentée dans la rubrique « Mise à jour » du README.md
Vérification hebdomadaire (≈ 20 min ; pratique recommandée à l'exploitant, non contractuelle) pnpm audit, pnpm outdated, lecture des avis de sécurité de Next.js, React et Node.js ; mise à jour des correctifs et versions mineures ; pnpm test ; docker compose build --pull (récupère les correctifs de l'image de base) ; déploiement ; contrôle de /api/health. Date et résultat consignés dans le journal de maintenance (tableau daté en fin de rubrique « Mise à jour » du README.md, §18.9.1)
Correctif critique Hors cycle hebdomadaire : mise à jour, tests et déploiement sous 48 h pour une vulnérabilité critique exploitable (Next.js, React, Node.js, sharp) (pratique recommandée à l'exploitant, non contractuelle ; Malka ne s'engage sur aucun délai vis-à-vis des visiteurs)
Surface minimale Pas de dépendance de la liste d'exclusion (§9.1) ; aucun script ni police chargés depuis un CDN ; images de base officielles uniquement (node, caddy)
Image Utilisateur non root, système de fichiers racine en lecture seule, capacités retirées, no-new-privileges (§14)

Critères d'acceptation 10.5

  • AC-10.5-01 — La CI exécute pnpm audit --prod --audit-level=high et échoue sur un avis de gravité haute (vérifié par un test de la commande sur un lockfile de démonstration).
  • AC-10.5-02 — La rubrique « Mise à jour » du README.md contient la liste de vérification hebdomadaire ci-dessus et le journal de maintenance.
  • AC-10.5-03 — Aucune dépendance de production n'est déclarée avec une plage latest, * ou une URL Git.

10.6 Protection des données personnelles (RGPD) #

10.6.1 Rôles #

Rôle Entité
Responsable de traitement Malka Debleds, identité exacte telle que saisie dans legal.publisher (§9.3)
Sous-traitant d'envoi d'e-mail Brevo (société française, données hébergées dans l'Union européenne)
Sous-traitant d'hébergement Fournisseur du VPS ou de la plateforme (legal.host)
Fournisseur de la boîte de réception Service de messagerie de l'adresse astheoris.pro@gmail.com (service de messagerie grand public) : destinataire des e-mails de contact, sous les conditions de ce fournisseur

10.6.2 Fiche de registre — « Demandes de contact via le site » #

À reprendre par Malka dans son registre des traitements (le README.md reprend ce tableau dans la rubrique « Sécurité et vie privée », §18.9.1). Seule la durée de conservation des messages est une valeur fournie par Malka (legal.privacy.retention) ; les autres durées (journaux, mémoire) et la conclusion sur l'analyse d'impact sont des propositions techniques que Malka valide avant la publication de la politique.

Rubrique Contenu
Finalité Recevoir et traiter les demandes de contact et de renseignements sur les accompagnements ; envoyer un accusé de réception au visiteur
Base légale Mesures précontractuelles prises à la demande de la personne (art. 6.1.b du RGPD) pour le traitement de la demande ; intérêt légitime (art. 6.1.f) pour l'accusé de réception et pour les journaux techniques de sécurité (empreinte salée d'IP, issue de la requête), afin de répondre aux sollicitations et de protéger le service contre les abus. La case à cocher obligatoire, non précochée, accompagnée d'un lien vers la politique de confidentialité, matérialise la prise de connaissance de l'information ; elle n'est pas la base légale du traitement
Personnes concernées Visiteurs et prospects qui soumettent le formulaire
Catégories de données Nom ; adresse e-mail ; téléphone (facultatif) ; offre souhaitée (facultatif) ; situation retenue dans la boussole d'orientation (facultatif, préremplie et modifiable) ; message libre (le visiteur choisit son contenu : la politique lui demande de ne pas y indiquer de données sensibles) ; données techniques : empreinte salée de l'adresse IP et issue de la requête, dans les journaux, et en mémoire pour la limitation de débit ; empreinte salée de l'adresse e-mail (24 h, en mémoire) pour le plafond d'accusés de réception
Données non collectées Pas de cookie, d'identifiant publicitaire, de géolocalisation, de donnée de paiement ; aucune donnée sensible demandée
Destinataires Malka Debleds ; sous-traitants : Brevo (transmission de l'e-mail), hébergeur ; fournisseur de la messagerie de réception
Transferts hors UE Brevo : traitement dans l'UE. Hébergeur : selon le pays déclaré dans legal.host. Fournisseur de messagerie de réception : relève de ses propres garanties de transfert, à mentionner dans la politique de confidentialité
Durée de conservation E-mails de contact dans la boîte de Malka : durée choisie et fournie par Malka dans legal.privacy.retention (obligatoire en production, aucune valeur par défaut livrée ; repère indicatif de §1.3 : la CNIL recommande, pour les données de prospection, 3 ans après le dernier contact) ; journaux techniques : 12 mois maximum (§10.6.7) ; état en mémoire : au plus 24 h et perdu au redémarrage ; aucune copie en base de données
Droits Accès, rectification, effacement, limitation, opposition, introduction d'une réclamation auprès de la CNIL (§10.6.5)
Mesures de sécurité HTTPS et HSTS, CSP et en-têtes (§10.3), validation stricte, limitation de débit, journaux sans donnée personnelle, secrets hors dépôt (§10.4), conteneur non privilégié (§14)
Analyse d'impact (AIPD) Non requise au regard des critères usuels (pas de donnée sensible traitée à dessein, pas de profilage ni de surveillance, volume faible) ; à reconsidérer si le périmètre évolue (calendrier en ligne, paiement)

10.6.3 Minimisation, cookies et traceurs #

  • Minimisation : seuls le nom, l'e-mail et le message sont obligatoires ; le téléphone, l'offre et la situation sont facultatifs ; l'accusé de réception ne répète aucune donnée saisie ; aucune donnée n'est stockée par l'application.
  • Aucun cookie, aucun stockage local : le site ne dépose et ne lit aucun cookie, localStorage ni sessionStorage. Seule exception : si l'analytique est activée, la page de politique de confidentialité propose un bouton d'opposition qui écrit un indicateur d'opposition (umami.disabled) à la demande explicite du visiteur (§10.6.4). Le choix de la boussole d'orientation n'est jamais persisté (§6.6).
  • Aucune bannière de consentement : sans cookie ni traceur soumis à consentement, aucune bannière n'est affichée. Cette décision est conditionnée au respect strict des points ci-dessus et de §10.6.4.
  • Aucune requête tierce au chargement : polices auto-hébergées au build, aucune image ni script externes ; WhatsApp n'est qu'un lien sortant (§10.8).
  • Contrôles automatisés : un test Playwright vérifie, après un parcours complet (navigation, menu, boussole, formulaire simulé), que document.cookie est vide, que localStorage et sessionStorage sont vides et que toutes les requêtes ont l'origine du site (avec analytique désactivée).

10.6.4 Mesure d'audience (Umami) et exemption CNIL #

L'analytique est désactivée par défaut. Elle ne peut être activée que sous la forme d'un outil de mesure d'audience configuré pour relever de l'exemption de consentement de la CNIL ; le MVP n'implémente aucun mécanisme de recueil de consentement (CMP).

Conditions à réunir pour l'exemption (liste de contrôle à valider par Malka avant d'activer) :

  1. Finalité strictement limitée à la mesure de l'audience du site (pages vues, événements du §9.8) pour l'usage exclusif de l'éditeur ; aucun usage publicitaire ou de profilage.
  2. Aucun identifiant persistant déposé ou lu sur le terminal (pas de cookie, pas de stockage local) ; Umami utilisé dans sa configuration sans cookie.
  3. Aucun recoupement avec d'autres traitements ni suivi inter-sites ; les données ne sont pas partagées avec des tiers pour leur propre compte.
  4. Données collectées limitées à des statistiques agrégées ; adresse IP non conservée en clair ; durée de conservation des données ne dépassant pas 25 mois ; instance configurée en conséquence.
  5. Information des visiteurs dans la politique de confidentialité (finalité, responsable, absence de cookie) et possibilité simple de s'y opposer : l'en-tête « Ne pas suivre » est respecté (data-do-not-track="true") et la page de politique de confidentialité propose le bouton d'opposition AnalyticsOptOut.
  6. Aucune donnée personnelle dans les événements : jamais d'identifiant de situation de la boussole, de contenu de formulaire ni d'identifiant utilisateur (§9.8.3).

Application technique de cette règle (interdire par configuration tout outil non exempté) :

Garde Mise en œuvre
Un seul fournisseur possible ANALYTICS_PROVIDER ∈ {none, umami} (énumération Zod, §13) ; toute autre valeur fait échouer le build
Paramètres du script figés data-do-not-track="true", data-auto-track="true", data-exclude-search="true", data-exclude-hash="true", aucun data-domains/data-tag/data-host-url (§9.8.2)
Origine contrôlée UMAMI_SCRIPT_URL en HTTPS (garde G-10) ; origine ajoutée à la CSP pour script-src et connect-src seulement
Politique de confidentialité La section « Mesure d'audience » et le bouton d'opposition ne sont rendus que si ANALYTICS_PROVIDER=umami (décision prise au build)
Aucun autre traceur Toute extension (autre outil, pixel publicitaire, intégration à cookies) est hors périmètre et exige une nouvelle spécification incluant un recueil de consentement conforme (bandeau avec refus aussi simple que l'acceptation)

AnalyticsOptOut : bouton à bascule qui pose ou retire localStorage['umami.disabled'] = '1' (indicateur lu par le script de mesure) et annonce l'état (« Vous n'êtes pas comptabilisé dans les statistiques » / « Vous êtes comptabilisé de façon anonyme »). Cet indicateur est un choix de l'utilisateur et non un traceur.

10.6.5 Exercice des droits #

  • Point de contact : l'adresse e-mail legal.publisher.email, indiquée dans la politique de confidentialité.
  • Procédure : vérification raisonnable de l'identité, puis réponse dans le délai d'un mois ; effacement = suppression des e-mails concernés dans la boîte de réception de Malka (les journaux ne contiennent aucune donnée permettant de retrouver une personne).
  • Réclamation : CNIL (www.cnil.fr), mentionnée dans la politique.
  • Retrait d'un témoignage publié : voir §10.7.3.

10.6.6 Sous-traitants et transferts #

  • Brevo : accepter l'accord de traitement des données (DPA) dans le compte ; désactiver le suivi des ouvertures et des clics pour les transactionnels ; indiquer dans la politique « Brevo (France), hébergement dans l'UE ».
  • Hébergeur du VPS : identité dans legal.host ; il n'accède pas aux données applicatives autrement que par l'infrastructure.
  • Messagerie de réception : le fournisseur de l'adresse de réception est destinataire des e-mails ; la politique le mentionne et renvoie à ses conditions ; Malka peut, si elle le souhaite, changer CONTACT_TO_EMAIL pour une boîte hébergée dans l'UE sans modification de code (§13).
  • WhatsApp : voir §10.8.
  • Umami (si activé) : l'instance est auto-hébergée par l'exploitant ou chez un prestataire lié par un accord de traitement ; elle est mentionnée dans la politique.

10.6.7 Conservation #

Donnée Lieu Durée maximale Mécanisme
E-mail de contact reçu Boîte de réception de Malka Durée fournie par Malka (legal.privacy.retention, §10.6.2) Suppression manuelle, rappel annuel dans la rubrique « Mise à jour » du README.md
Copie transitoire de l'e-mail Journaux d'envoi de Brevo Durée minimale proposée par Brevo pour les journaux transactionnels (à régler dans le compte) Paramètre du compte
Journaux applicatifs (empreinte d'IP salée, issue, horodatage) Sortie standard du conteneur (rotation json-file 10 Mo × 5, §14) 12 mois Toute reconstruction ou recréation du conteneur (au moins à chaque déploiement, au minimum chaque trimestre selon la vérification de §10.5) supprime les journaux de l'ancien conteneur
État du limiteur et de la déduplication Mémoire du processus ≤ 24 h Purge périodique et au redémarrage
Journal d'accès du proxy Désactivé par la configuration livrée (§14) — Si l'exploitant en active un, il doit l'ajouter à la politique et limiter sa durée à 12 mois

10.6.8 Contenu minimal de la politique de confidentialité #

La page /politique-de-confidentialite (§6.11, texte en §7) contient obligatoirement, à partir de legal.ts et site.ts : identité et contact du responsable ; finalités ; bases légales ; catégories de données ; destinataires et sous-traitants (Brevo, hébergeur, messagerie de réception) ; transferts hors UE ; durées de conservation (§10.6.7, texte legal.privacy.retention pour les messages) ; droits et procédure (§10.6.5) ; réclamation auprès de la CNIL ; mention « aucun cookie, aucun traceur » ; WhatsApp comme service tiers ; mesure d'audience uniquement si activée (§10.6.4) ; conseil de ne pas saisir de données sensibles dans le message ; date de mise à jour (legal.lastUpdated, balise <time>). Une entrée du pied de page et de la case de consentement du formulaire y renvoie.

10.7 Obligations légales françaises #

10.7.1 Mentions légales #

La page /mentions-legales (LCEN, art. 6-III) présente, depuis legal.ts : l'identité de l'éditeur (dénomination ou nom, forme juridique, adresse, SIRET, e-mail, téléphone), le capital social et l'immatriculation lorsque l'éditeur est une société, le directeur ou la directrice de la publication, l'identité de l'hébergeur (nom, adresse, téléphone), la mention relative à la TVA, le médiateur de la consommation si Malka en désigne un, et la date de mise à jour. Aucune valeur n'est inventée : les champs vides font échouer le build de production (G-01, §9.3.7). La politique de confidentialité affiche en outre la durée de conservation des messages fournie dans legal.privacy.retention (même garde).

10.7.2 Prix et TVA #

Chaque offre affiche « Tarif » suivi du montant formaté fr-FR (« 300 € », « 1 000 € ») ou « Sur devis » (§9.3.4). Sous la grille des offres, la mention legal.vatMention précise le régime applicable (par exemple la mention de franchise en base de TVA si Malka en relève, ou le caractère TTC des prix). Cette mention est fournie par Malka ou son comptable, jamais déduite (garde G-01). Aucun prix barré, remise ou prix « à partir de » n'est affiché sans décision de Malka.

10.7.3 Témoignages et avis #

  • Pas de faux avis : aucun témoignage n'est inventé, reformulé ou traduit ; la citation est copiée mot pour mot après validation par la personne ; les faux avis relèvent des pratiques commerciales trompeuses (Code de la consommation, art. L. 121-2 et suivants).
  • Consentement de publication : chaque témoignage publié exige un consentement écrit daté (consent.grantedOn) et son canal (consent.channel) ; la preuve est conservée par Malka hors du dépôt.
  • Information sur les avis : lorsque des avis sont diffusés, le Code de la consommation (art. L. 111-7-2 et D. 111-17 et suivants) exige une information loyale, claire et transparente sur leur traitement. Le site affiche donc, dans la section, la note testimonials.verificationNote (rédigée par Malka, par exemple pour indiquer que les témoignages sont publiés avec l'accord de leurs auteurs, sans modification, et comment ils ont été recueillis). La règle X-10 (§9.3.5) rend cette note obligatoire dès qu'un témoignage est publiable ; elle ne doit contenir que des affirmations que Malka peut démontrer.
  • Données personnelles de l'auteur : le nom et le contexte professionnel sont des données personnelles ; la base légale est le consentement ; aucune photo de client, aucun portrait généré.
  • Retrait : sur demande, published passe à false et le site est redéployé sans délai excessif (au plus 30 jours, délai légal d'un mois).

10.7.4 Hypothèses de conformité #

  • Le site ne conclut aucune vente en ligne (pas de paiement, pas de panier) : aucune page de conditions générales de vente ni de rétractation n'est livrée dans le MVP ; les conditions applicables sont communiquées par Malka avant la prestation, hors du site. L'ajout d'un paiement ou d'une réservation en ligne (§9.9) rouvre ce point.
  • Public visé (B2B / B2C), hypothèse à confirmer par écrit par Malka avant la production (élément M-22 de §1.3) : les prestations sont vendues à des professionnels. Un contrat conclu à distance avec un consommateur emporte des obligations même sans paiement en ligne. Si Malka vend aussi à des consommateurs, elle fournit (a) des conditions générales avec l'information précontractuelle et les modalités de rétractation, (b) le médiateur de la consommation, qui devient alors obligatoire dans legal.mediator (la garde G-01 est étendue en conséquence), et (c) une page /conditions-generales est ajoutée au site (évolution, hors MVP).
  • Malka confirme avec son conseil l'applicabilité du médiateur de la consommation à son activité ; si elle en désigne un, legal.mediator est renseigné et affiché.
  • Aucune allégation de santé ni promesse de résultat n'apparaît dans le contenu (garde de qualité, §9.3.6).

10.8 WhatsApp : service tiers #

  • WhatsApp est un lien sortant (https://wa.me/…) : aucun SDK, aucun widget, aucun embed, aucune requête vers WhatsApp au chargement de la page.
  • Le lien s'ouvre dans un nouvel onglet (target="_blank" rel="noopener noreferrer") ; noreferrer et la politique strict-origin-when-cross-origin limitent les informations transmises.
  • Une fois sur WhatsApp, le traitement des données relève de WhatsApp et de ses conditions ; la politique de confidentialité le dit et renvoie vers ses conditions. Le message prérempli ne contient aucune donnée personnelle.
  • Le numéro est public (il est affiché sur le site) ; il est lu depuis site.contact uniquement.

10.9 Accessibilité des pages légales #

Les pages /mentions-legales et /politique-de-confidentialite respectent WCAG 2.2 AA (cibles et mesures en §11) avec les exigences propres suivantes : un seul <h1>, hiérarchie de titres sans saut, <main> unique ; longueur de ligne ≤ 75 caractères ; contraste ≥ 4,5:1 ; liens soulignés et identifiables sans la couleur ; aucune animation d'entrée ni de parallaxe (pages statiques calmes) ; lien de retour à l'accueil ; ancres internes pour les rubriques longues ; date de mise à jour dans un élément <time datetime> ; <html lang="fr">. Le bandeau de développement de §6.11 (role="note") n'existe jamais en production.

10.10 Gestion des incidents #

10.10.1 Rotation de la clé Brevo (planifiée ou après fuite) #

  1. Dans le compte Brevo, créer une nouvelle clé API dédiée au site (nom explicite et date), sans révoquer l'ancienne. Si la fuite est avérée, révoquer l'ancienne immédiatement à l'étape 1 et accepter une brève interruption de l'envoi.
  2. Mettre à jour BREVO_API_KEY dans l'environnement d'exécution : fichier .env du serveur (permissions 600) ou variable « runtime » de Coolify (jamais « build »).
  3. Recréer le conteneur pour relire l'environnement : docker compose up -d --force-recreate web (ou « Redeploy » dans Coolify).
  4. Vérifier : GET /api/health répond 200 ; envoyer une demande de test depuis le formulaire public ; la notification arrive dans la boîte de Malka ; le journal contient email.send avec outcome:"ok".
  5. Révoquer l'ancienne clé dans Brevo et vérifier qu'aucun envoi ne l'utilise encore.
  6. Effacer la clé des supports temporaires (historique de terminal, notes) ; ne jamais la coller dans un message.
  7. Après une fuite : consulter l'historique d'envoi de Brevo pour détecter des e-mails non émis par le site ; si des données personnelles ont pu être exposées, évaluer l'obligation de notification à la CNIL sous 72 h (art. 33 du RGPD) et d'information des personnes ; consigner l'incident.
  8. Faire tourner LOG_SALT lors de la même opération (génération dans le terminal : openssl rand -hex 32, puis coller le résultat dans .env ; aucune substitution de commande n'est évaluée dans ce fichier) ; effet : les empreintes d'IP des journaux antérieurs ne sont plus corrélables, sans autre conséquence.

10.10.2 Autres incidents #

Situation Action immédiate Suivi
Vague de spam ou plainte de réception d'accusés non sollicités Mettre SEND_ACKNOWLEDGEMENT=false et recréer le conteneur ; bloquer les adresses fautives au pare-feu du VPS ou au proxy Analyser les journaux (spam_dropped, rate_limited, ack_suppressed) ; réactiver l'accusé après la vague
EMAIL_DELIVERY_FAILED en série Lire email.send (providerStatus, providerCode) : 401/403 ⇒ clé (§10.10.1) ; 400 ⇒ expéditeur ou domaine non vérifié ; 429 ⇒ quota ; 5xx ⇒ panne fournisseur Les visiteurs voient les alternatives WhatsApp et e-mail ; aucun message n'est perdu côté formulaire (le texte reste en place)
Compromission suspectée du serveur Isoler le VPS, reconstruire l'image depuis le dépôt, faire tourner tous les secrets, changer les accès SSH Évaluer l'exposition (aucune base de données ; seule la messagerie et la clé Brevo sont en jeu)
Coupe-circuit global saturé (réponses 429 pour tous les visiteurs) Lire ratelimit.blocked avec rule=global_1h et l'origine des requêtes (ipHash, spam_dropped) ; le formulaire affiche déjà les alternatives WhatsApp et e-mail ; si l'abus est avéré, bloquer les adresses fautives au pare-feu ou au proxy ; relever au besoin globalPerHour (§9.6.2) par un redéploiement Réexaminer les plafonds après l'incident ; consigner la date dans le journal de maintenance
Contenu erroné publié (prix, mention) Corriger le fichier de contenu, redéployer Consigner la date de correction dans le journal de maintenance du README.md

Critères d'acceptation 10 (transverses)

  • AC-10.6-01 — Après le parcours complet de §10.6.3, document.cookie est vide, localStorage et sessionStorage sont vides, et aucune requête n'est émise vers une autre origine (analytique désactivée).
  • AC-10.6-02 — Avec ANALYTICS_PROVIDER=umami, la page de politique de confidentialité affiche la section « Mesure d'audience » et le bouton d'opposition ; avec none, ni l'un ni l'autre ; le bouton bascule bien localStorage['umami.disabled'].
  • AC-10.6-03 — ANALYTICS_PROVIDER=ga4 (ou toute valeur hors énumération) fait échouer le build avec un message nommant la variable.
  • AC-10.6-04 — Le formulaire contient une case non précochée, obligatoire, avec lien vers la politique et attestant la lecture de l'information ; l'envoi sans case cochée est refusé côté client et serveur (422 consent) ; la politique de confidentialité cite l'article 6.1.b et l'article 6.1.f comme bases légales et précise que la case n'en est pas une.
  • AC-10.6-05 — La politique de confidentialité contient toutes les rubriques de §10.6.8 (test de présence des titres).
  • AC-10.7-01 — Sur une version de production, les pages légales ne contiennent aucune valeur vide, aucune chaîne de développement et affichent la date de mise à jour.
  • AC-10.7-02 — Avec 0 témoignage publiable, la section n'existe pas ; avec ≥ 1, la note de vérification est présente et non vide.
  • AC-10.7-03 — Chaque lien WhatsApp est un lien sortant rel="noopener noreferrer" sans script ni iframe WhatsApp dans le DOM ; aucune requête vers wa.me n'est émise avant un clic.
  • AC-10.10-01 — La rubrique « Rotation de la clé d'API » du README.md (§18.9.1) reprend la procédure de §10.10.1, y compris la rotation de LOG_SALT et la gestion d'une fuite avérée.
  • AC-10.10-02 — SEND_ACKNOWLEDGEMENT=false supprime effectivement l'envoi de l'accusé (test d'intégration) tout en conservant la notification.

11. SEO, accessibilité et performance #

Cette section fixe les exigences techniques de référencement, d'accessibilité (WCAG 2.2 niveau AA) et de performance. Les textes affichés (titres, descriptions, libellés) sont en section 7 ; les tokens visuels et contrastes en section 5 ; les mouvements en section 8 ; les variables d'environnement en section 13 ; la stratégie de test globale en section 15. Les versions des dépendances sont en §9.1.

11.1 SEO technique #

11.1.1 Principes #

  1. Le site est une page d'accueil unique (/) accompagnée de deux pages légales ; le référencement repose sur un seul document riche, des métadonnées exactes et un balisage structuré limité aux données réelles.
  2. L'indexation n'est autorisée qu'en production (APP_ENV=production, voir §13). Tout autre environnement est fermé aux moteurs de recherche à trois niveaux : robots.txt, balise meta robots et en-tête X-Robots-Tag (§11.1.7).
  3. Aucun contenu n'est inventé pour le SEO : pas de note, pas d'avis, pas d'adresse postale, pas de profils sociaux fictifs, pas de mots-clés cachés.
  4. Les titres et descriptions sont fournis par le fichier de contenu src/content/fr/seo.ts (schéma en §9.3, textes en §7). Ce chapitre fixe leur utilisation et leurs contraintes.

11.1.2 URL canoniques et metadataBase #

  • SITE_URL (variable de build, voir §13) est la seule source d'origine. Sa validation et sa normalisation sont faites une fois pour toutes par getBuildEnv() (§13.4) : suppression de la barre oblique finale ; en production, schéma https:, hôte ni localhost ni adresse IP (sinon le build échoue avec un message explicite). src/lib/seo/site-url.ts ne lit donc aucune variable d'environnement directement : il appelle getBuildEnv(). Le sous-domaine www n'est pas utilisé dans SITE_URL (la redirection www → apex est assurée par le proxy, voir §14).
  • Format des URL : sans barre oblique finale (trailingSlash: false), sans paramètre de requête, sans fragment. Exemples : https://exemple.fr/, https://exemple.fr/mentions-legales.
  • Le champ metadataBase du layout racine vaut new URL(siteUrl). Toute URL de métadonnée est ensuite écrite en chemin relatif ('/', '/mentions-legales', '/og/og-default.jpg') et résolue en absolu par Next.js.
  • Chaque page déclare alternates.canonical égal à son propre chemin. Une URL avec paramètre ou fragment (ex. /?utm=…, /#contact) a pour canonique /.
// src/lib/seo/site-url.ts
import 'server-only';
import { getBuildEnv } from '@/lib/env';

export const getSiteUrl = (): URL => new URL(getBuildEnv().SITE_URL);   // déjà validée et normalisée (§13.4)
export const absoluteUrl = (path: string): string => new URL(path, getSiteUrl()).toString();
export const isProduction = (): boolean => getBuildEnv().APP_ENV === 'production';

11.1.3 Métadonnées par route #

Route Titre rendu Description robots Canonique Source
/ seo.routes.home.title (absolu, sans gabarit) seo.routes.home.description index, follow (prod) / seo.ts
/mentions-legales seo.routes.mentionsLegales.title + gabarit → « {titre} — Malka Debleds » seo.routes.mentionsLegales.description index, follow (prod) /mentions-legales seo.ts
/politique-de-confidentialite seo.routes.politiqueConfidentialite.title + gabarit seo.routes.politiqueConfidentialite.description index, follow (prod) /politique-de-confidentialite seo.ts
404 (not-found) seo.routes.notFound.title + gabarit seo.routes.notFound.description noindex, nofollow (toujours) — (aucune) seo.ts
Page d'erreur (error) Composant client : pas de métadonnée propre, hérite du layout — hérite — —
/api/* — — En-tête X-Robots-Tag: noindex, nofollow — next.config.ts (§11.1.7)

Les clés lues sont exactement celles de seoSchema (§9.3.3) : siteName, locale, jobTitle, defaultOgImageAlt, routes.{home, mentionsLegales, politiqueConfidentialite, notFound} (la route home ajoute ogTitle et ogDescription) et jsonLd.enabled. L'image de partage n'est pas configurable : son chemin est la constante /og/og-default.jpg.

Contraintes de contenu (vérifiées à la validation Zod du build par seoSchema, §9.3.3) :

  • title : 20 à 60 caractères, sans U+00A0/U+202F (les espaces insécables du contenu typographié sont normalisées par stripFrTypo, §5.5.6) ;
  • description : 110 à 160 caractères, une seule phrase ou deux, sans promesse de résultat, sans mention thérapeutique ;
  • chaque titre est unique parmi les routes ;
  • le titre de / contient « Malka Debleds » et « Entreprise vivante » (marque) — le texte exact est en §7.
// src/app/layout.tsx (extraits)
import type { Metadata, Viewport } from 'next';
import { getContent } from '@/lib/content';
import { THEME_COLOR } from '@/lib/design/tokens';
import { getSiteUrl, isProduction } from '@/lib/seo/site-url';
import { stripFrTypo } from '@/lib/typography/fr';

const { seo } = getContent('fr');
const home = seo.routes.home;
const OG_IMAGE = '/og/og-default.jpg';

export const metadata: Metadata = {
  metadataBase: getSiteUrl(),
  title: { default: stripFrTypo(home.title), template: '%s — Malka Debleds' },
  description: stripFrTypo(home.description),
  applicationName: 'Malka Debleds',
  authors: [{ name: 'Malka Debleds' }],
  creator: 'Malka Debleds',
  formatDetection: { telephone: false, email: false, address: false }, // les numéros sont liés explicitement
  robots: isProduction()
    ? { index: true, follow: true }
    : { index: false, follow: false, nocache: true },
  openGraph: {
    type: 'website',
    siteName: 'Malka Debleds',
    locale: 'fr_FR',
    url: '/',
    title: stripFrTypo(home.ogTitle),
    description: stripFrTypo(home.ogDescription),
    images: [{ url: OG_IMAGE, width: 1200, height: 630, alt: seo.defaultOgImageAlt }],
  },
  twitter: {
    card: 'summary_large_image',
    title: stripFrTypo(home.ogTitle),
    description: stripFrTypo(home.ogDescription),
    images: [OG_IMAGE],
  },
};

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,           // aucun maximumScale ni userScalable=no : le zoom reste libre (WCAG 1.4.4)
  themeColor: THEME_COLOR,   // forest-900, défini dans src/lib/design/tokens.ts (§5.1.3) : aucun littéral hexadécimal ici
  colorScheme: 'light',
};
// src/app/page.tsx (extrait) — la page d'accueil définit son titre absolu et sa canonique
export const metadata: Metadata = {
  title: { absolute: stripFrTypo(seo.routes.home.title) },
  alternates: { canonical: '/' },
};
// src/app/mentions-legales/page.tsx (modèle pour les deux pages légales)
const meta = seo.routes.mentionsLegales;   // politiqueConfidentialite pour l'autre page
export const metadata: Metadata = {
  title: stripFrTypo(meta.title),          // gabarit « %s — Malka Debleds »
  description: stripFrTypo(meta.description),
  alternates: { canonical: '/mentions-legales' },
  openGraph: { url: '/mentions-legales', title: stripFrTypo(meta.title), description: stripFrTypo(meta.description) },
};

Le gabarit de titre n'est pas appliqué à / (titre absolu) afin d'éviter la répétition « Malka Debleds — … — Malka Debleds ».

11.1.4 Open Graph et Twitter #

Propriété Valeur
og:type website
og:site_name Malka Debleds
og:locale fr_FR
og:url Canonique absolue de la page
og:title / og:description Page d'accueil : seo.routes.home.ogTitle / ogDescription ; pages légales : title / description de la page (espaces normales)
og:image /og/og-default.jpg (absolue après résolution), 1200 × 630, og:image:alt renseigné depuis seo.defaultOgImageAlt
twitter:card summary_large_image
twitter:title / twitter:description / twitter:image Identiques à l'Open Graph
twitter:site / twitter:creator Non émis (aucun compte fourni)

Règles : une seule image de partage pour toutes les pages au lancement ; l'image est un fichier statique (§5.9.2), jamais générée à la volée ; elle ne contient aucun visage généré. Aucune balise article:*, fb:app_id ni og:video.

11.1.5 Données structurées JSON-LD #

Un unique bloc <script type="application/ld+json"> est rendu sur / uniquement, par le composant serveur <JsonLd> (src/lib/seo/json-ld.tsx), appelé depuis page.tsx. Il ne contient que des données réelles issues de site.ts et seo.ts. <JsonLd> est le seul emploi de dangerouslySetInnerHTML du dépôt (§12.8.1) ; le bloc est omis si seo.jsonLd.enabled vaut false.

// src/lib/seo/json-ld.tsx
import { absoluteUrl } from './site-url';
import { stripFrTypo } from '@/lib/typography/fr';
import type { Content } from '@/lib/content/types';

export function buildJsonLd(site: Content['site'], seo: Content['seo']) {
  const base = absoluteUrl('/');
  const personId = `${base}#malka-debleds`;
  const serviceId = `${base}#service`;
  return {
    '@context': 'https://schema.org',
    '@graph': [
      {
        '@type': 'Person',
        '@id': personId,
        name: site.brandName,                         // 'Malka Debleds'
        jobTitle: stripFrTypo(seo.jobTitle),
        url: base,
        email: site.contact.email,
        telephone: site.contact.phoneE164,            // '+33667295179'
        knowsLanguage: 'fr',
        worksFor: { '@id': serviceId },
      },
      {
        '@type': 'ProfessionalService',
        '@id': serviceId,
        name: site.brandName,
        description: stripFrTypo(seo.routes.home.description),
        url: base,
        email: site.contact.email,
        telephone: site.contact.phoneE164,
        areaServed: { '@type': 'Country', name: 'France' },
        availableLanguage: 'French',
      },
    ],
  } as const;
}

/** Sérialisation sûre : échappe « < » pour empêcher toute sortie de balise <script>. */
export const serializeJsonLd = (data: unknown): string =>
  JSON.stringify(data).replace(/</g, '\\u003c');

export function JsonLd({ data }: { data: unknown }) {
  return (
    <script
      type="application/ld+json"
      // eslint-disable-next-line react/no-danger -- JSON-LD serialised by JSON.stringify, never fed by visitor input
      dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
    />
  );
}
  • Valeur de seo.jobTitle : le positionnement fourni par Malka (« professionnelle de l'accompagnement vibratoire et stratégique des entrepreneurs » ; texte exact en §7 ; aucune formulation médicale ou thérapeutique).
  • Aucune assertion non fournie : le balisage n'affirme ni fondation, ni profils tiers, ni récompense. Le lien entre la personne et le service se limite à worksFor.
  • Interdits (test unitaire, §11.1.9) : aggregateRating, review, reviewRating, address, geo, openingHours, priceRange, sameAs, founder, image, logo, award, hasCredential, makesOffer/hasOfferCatalog (les prix restent affichés dans la page ; ils ne sont pas répétés dans le balisage pour éviter toute dérive), et toute chaîne vide.
  • Adresse : jamais émise dans le JSON-LD, même si l'adresse légale est renseignée dans legal.ts : l'accompagnement est à distance et l'adresse est publiée uniquement dans les mentions légales, comme l'exige la loi.
  • Le bloc est un document de données (type MIME non exécutable) : il n'est pas soumis à script-src de la politique de sécurité (voir §10) et ne nécessite ni nonce ni hachage.
// src/app/page.tsx (extrait)
const { site, seo } = getContent('fr');
// …
{seo.jsonLd?.enabled !== false && <JsonLd data={buildJsonLd(site, seo)} />}

11.1.6 sitemap.ts et robots.ts #

// src/app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getContent } from '@/lib/content';
import { absoluteUrl } from '@/lib/seo/site-url';

export const dynamic = 'force-static';

export default function sitemap(): MetadataRoute.Sitemap {
  // Toujours les 3 URL : la fermeture hors production est assurée par robots.txt, noindex et X-Robots-Tag (§11.1.7).
  const { legal } = getContent('fr');
  const legalDate = legal.lastUpdated ? new Date(legal.lastUpdated) : undefined; // 'YYYY-MM-DD'
  return [
    { url: absoluteUrl('/'), changeFrequency: 'monthly', priority: 1 },
    { url: absoluteUrl('/mentions-legales'), lastModified: legalDate, changeFrequency: 'yearly', priority: 0.3 },
    { url: absoluteUrl('/politique-de-confidentialite'), lastModified: legalDate, changeFrequency: 'yearly', priority: 0.3 },
  ];
}
// src/app/robots.ts
import type { MetadataRoute } from 'next';
import { absoluteUrl, isProduction } from '@/lib/seo/site-url';

export const dynamic = 'force-static';

export default function robots(): MetadataRoute.Robots {
  if (!isProduction()) {
    return { rules: [{ userAgent: '*', disallow: '/' }] };           // staging, développement, tests
  }
  return {
    rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
    sitemap: absoluteUrl('/sitemap.xml'),
  };
}

Règles : l'URL / n'a pas de lastModified (aucune date de modification fiable côté contenu) ; les ancres (/#contact) ne figurent jamais dans le plan du site ; l'inclusion ou non de la section Témoignages n'a aucun effet sur le plan ; la route 404 n'y figure pas ; robots.txt et sitemap.xml sont générés au build. Le plan du site liste toujours ses trois URL, quel que soit l'environnement ; seul robots.txt dépend de APP_ENV (figé au build, voir §13) et ferme les environnements hors production avec Disallow: /.

11.1.7 Fermeture des environnements hors production #

Hors production, trois verrous cumulés : (1) robots.txt interdit tout ; (2) metadata.robots produit <meta name="robots" content="noindex, nofollow, nocache"> ; (3) un en-tête X-Robots-Tag: noindex, nofollow, noarchive est ajouté à toutes les réponses. Le plan du site (sitemap.xml) reste présent : ces trois verrous suffisent.

Il n'existe qu'une seule source des règles d'en-têtes HTTP de l'application : le constructeur pur de src/lib/security/headers.ts (§10.3.3), appelé par la fonction headers() de next.config.ts, qui ne contient aucune règle propre. Il produit, dans une même liste de règles ordonnée, les en-têtes de sécurité (§10), les règles X-Robots-Tag ci-dessous et les règles Cache-Control de §11.3.8. Le proxy inverse n'ajoute aucun en-tête (il assure le TLS et la compression, §14).

Chemin En-tête Valeur Condition
/api/:path* X-Robots-Tag noindex, nofollow Toujours
/:path* X-Robots-Tag noindex, nofollow, noarchive APP_ENV ≠ production

11.1.8 Plan de titres (outline) et sémantique #

Un seul <h1> par page ; aucun niveau sauté ; l'ordre visuel suit l'ordre du DOM.

Niveau Élément Où Remarque
h1 Titre du hero (« Retrouver la clarté… », texte en §7) #accueil Unique sur / ; rendu dans le HTML serveur
h2 Titre de la Philosophie #philosophie
h2 Titre de la Méthode #methode
h3 ×4 Titre de chaque étape (Écouter, Explorer, Clarifier, Décider) Panneaux de la boussole Les quatre panneaux restent montés ; les panneaux non sélectionnés sont visibility: hidden et inert (exclus du plan accessible)
h2 Titre des Accompagnements #accompagnements
h3 ×4 Titre de chaque offre OfferItem
h2 Titre de l'Orientation #orientation
h3 Titre du parcours de réflexion affiché après choix Zone de résultat Présent seulement après sélection
h2 Titre « À propos » #a-propos
h2 Titre des Témoignages #temoignages Absent si aucun témoignage publiable
h2 Titre du Contact #contact
h3 Sous-titres des deux modes de contact (WhatsApp, formulaire) Contact Voir §6.9
h1 Titre de la page Pages légales, 404 Sections en h2, sous-sections en h3

Le footer ne contient aucun titre : le titre de la carte CTA flottante est un paragraphe stylé (<p>, §5.12.14) et ses groupes de liens sont nommés par aria-label. Le mot « CLARTÉ » géant et le logotype ne sont pas des titres. Les libellés de lien sont descriptifs hors contexte (« Politique de confidentialité », jamais « Cliquez ici »). Les liens externes (WhatsApp) portent rel="noopener noreferrer".

11.1.9 Divers #

  • <html lang="fr"> ; aucune alternative linguistique : pas de hreflang ni de alternates.languages au lancement (le français est la seule langue ; la structure src/content/fr et getContent(locale) préparent l'extension).
  • Icônes : src/app/icon.svg (monogramme « M » en Fraunces champagne-400 sur carré arrondi forest-900, composition purement typographique, sans visage) et src/app/apple-icon.png (180 × 180) ; pas de manifeste d'application web.
  • Les pages /mentions-legales et /politique-de-confidentialite sont indexables et liées depuis le footer ; /politique-de-confidentialite mentionne l'analytique uniquement si elle est activée (§10).
  • Les redirections http → https et www → apex sont des 301 gérées par le proxy (§14) ; l'application ne fait aucune redirection d'hôte.
  • Content-Language: fr n'est pas ajouté (l'attribut lang suffit).

11.1.10 Critères d'acceptation SEO #

ID Critère Vérification
AC-11.1-01 En production, chaque route indexable expose un <title> unique de 20 à 60 caractères et une meta description de 110 à 160 caractères, sans U+00A0/U+202F. Test unitaire sur seo.ts + E2E sur le HTML
AC-11.1-02 Chaque page indexable a exactement une balise <link rel="canonical"> absolue, construite depuis SITE_URL, sans barre finale ni paramètre. E2E
AC-11.1-03 Le HTML de / contient og:title, og:description, og:image (1200 × 630, absolue, HTTP 200), og:url, og:locale=fr_FR, og:site_name, twitter:card=summary_large_image. E2E
AC-11.1-04 Le JSON-LD de / est un JSON valide contenant exactement un Person et un ProfessionalService reliés par @id (worksFor), ne contient aucune clé interdite de §11.1.5 (dont founder et sameAs), et le téléphone/e-mail correspondent à site.ts. Test unitaire + validation schema.org
AC-11.1-05 Le JSON-LD ne contient aucun caractère < brut ; il passe JSON.parse. Test unitaire
AC-11.1-06 Avec APP_ENV=production, robots() autorise /, interdit /api/ et cite …/sitemap.xml. Dans tous les environnements, sitemap() liste exactement 3 URL absolues (/, /mentions-legales, /politique-de-confidentialite) ; en production, toutes répondent en HTTP 200. Test unitaire (environnement simulé) + contrôle curl du runbook de mise en production (§14)
AC-11.1-07 Avec APP_ENV≠production, /robots.txt contient Disallow: /, /sitemap.xml liste les mêmes 3 URL, chaque réponse HTML porte <meta name="robots" content="noindex…"> et l'en-tête X-Robots-Tag: noindex, nofollow, noarchive. E2E sur le service de test (build staging)
AC-11.1-08 Un seul <h1> par page ; aucun saut de niveau (h1 → h3 sans h2) ; la section Témoignages absente n'apparaît ni dans le plan ni dans la navigation. axe + test de plan de titres
AC-11.1-09 La page 404 renvoie le statut HTTP 404 et noindex. E2E
AC-11.1-10 Aucune balise hreflang n'est émise. E2E
AC-11.1-11 Le viewport ne contient ni maximum-scale ni user-scalable=no. E2E
AC-11.1-12 Les réponses /api/* portent X-Robots-Tag: noindex, nofollow. Test d'intégration
AC-11.1-13 Les règles X-Robots-Tag et Cache-Control proviennent du seul constructeur de src/lib/security/headers.ts ; next.config.ts ne définit aucune règle d'en-tête propre ; le proxy n'ajoute aucun en-tête. Test unitaire + revue de next.config.ts et du Caddyfile

11.2 Accessibilité (WCAG 2.2 niveau AA) #

11.2.1 Cible et méthode #

  • Conformité visée : WCAG 2.2 niveau AA sur toutes les routes, tous les états décrits et les trois familles d'écran (mobile 320–767, tablette 768–1023, desktop ≥ 1024).
  • Trois couches de vérification : (1) analyse statique/lint (eslint-plugin-jsx-a11y) ; (2) tests automatisés @axe-core/playwright avec les balises wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa, exécutés dans chaque état interactif listé en §11.2.12, avec comme seuil bloquant 0 violation d'impact serious ou critical (les violations moderate et minor sont corrigées ou consignées et justifiées dans docs/a11y-report.md) ; (3) protocole manuel clavier + lecteurs d'écran (§11.2.12). Lighthouse Accessibilité doit rester à 100.
  • L'automatisation couvre environ un tiers des critères : le protocole manuel est obligatoire avant la mise en production.

11.2.2 Matrice des critères WCAG 2.2 AA #

Critère Exigence appliquée Composants / sections concernés Réf.
1.1.1 Contenu non textuel Paysages et calques alt="" ; portraits avec alt factuel ; icônes aria-hidden ; « CLARTÉ » aria-hidden Hero, À propos, Footer, Icon §5.8, §5.9
1.3.1 Information et relations Titres réels, listes réelles, fieldset/legend, label for, figure/blockquote, tableaux de données inexistants Toutes §11.1.8
1.3.2 Ordre séquentiel logique L'ordre du DOM suit l'ordre de lecture (texte du hero avant portrait et barre de méthode) Hero, Contact §6.2
1.3.3 Caractéristiques sensorielles Aucune consigne du type « cliquez sur le bouton rond à droite » Copy §7
1.3.4 Orientation Aucun verrouillage portrait/paysage Global —
1.3.5 Identifier la finalité autocomplete : name, email, tel ContactForm §5.12.12
1.4.1 Utilisation de la couleur Liens soulignés ; état actif = couleur + filet + aria-current ; erreur = icône + texte ; sélection = icône Check NavLink, TextLink, ChoiceChip, FormField §5.12
1.4.3 Contraste (minimum) Tableaux §5.3 ; texte sur image ≥ 4,5 : 1 mesuré Toutes les sections §5.3, AC-5.4-03
1.4.4 Redimensionnement du texte Unités rem, aucune hauteur fixe de texte, zoom 200 % sans perte Toutes §11.2.11
1.4.5 Texte sous forme d'image Aucun texte dans les images de contenu Images §5.9
1.4.10 Redistribution (reflow) Pas de défilement 2D à 320 px CSS (zoom 400 %) Toutes §11.2.11
1.4.11 Contraste du contenu non textuel ≥ 3 : 1 pour bordures de champs, focus, icônes porteuses FormField, focus, Check §5.3.6
1.4.12 Espacement du texte Aucun masque ou conteneur ne rogne le texte avec les valeurs WCAG Titres en masques, cartes §11.2.11
1.4.13 Contenu au survol ou au focus Aucune infobulle ; la sélection de la boussole par survol est persistante MethodCompass §6.4
2.1.1 Clavier Toutes les fonctions au clavier (cartes §11.2.5) Toutes §11.2.5
2.1.2 Pas de piège au clavier Le menu mobile (dialogue plein écran) se quitte par Échap ; pas d'autre piège Sheet §5.12.15
2.1.4 Raccourcis clavier Aucun raccourci à touche unique Global —
2.2.1 Délai réglable Aucune limite de temps visible ou exigée de l'utilisateur ContactForm §9.6
2.2.2 Pause, arrêt, masquage Pas de lecture automatique ; animations d'ambiance limitées à un cycle unique de 5 s au plus par entrée dans le viewport, retour au repos symétrique (§11.2.9) Hero, Footer §8
2.3.1 Pas plus de trois flashs Aucun clignotement ; variations d'opacité lentes Global §8
2.4.1 Contourner des blocs Lien d'évitement « Aller au contenu » + repères SkipLink §5.12.1
2.4.2 Titre de page Titre unique par route Métadonnées §11.1.3
2.4.3 Parcours du focus Ordre logique ; focus rendu au déclencheur à la fermeture du menu Header, Sheet §11.2.4
2.4.4 Fonction du lien Libellé explicite ; noms accessibles des CTA d'offres incluant le titre NavLink, OfferItem §5.12.8
2.4.5 Accès multiples Navigation d'en-tête, navigation du pied de page, pages légales liées Header, Footer §6.1, §6.10
2.4.6 En-têtes et étiquettes Titres et labels descriptifs Toutes §7
2.4.7 Focus visible Anneau 2 px décalé de 2 px, jamais supprimé Global §5.7.5
2.4.11 Focus non masqué (minimum) scroll-padding-top = header + 16 px ; header toujours visible, seul élément fixe hors panneau du menu mobile ; carte CTA non fixe Header, FloatingCtaCard §11.2.6
2.5.1 Gestes de pointeur Aucun geste multipoint ou tracé requis Slider, Compass §11.2.8
2.5.2 Annulation du pointeur Actions sur click (relâchement), jamais sur pointerdown Boutons §5.12
2.5.3 Étiquette dans le nom Le nom accessible commence par le texte visible Button, OfferItem §5.12.8
2.5.4 Activation par le mouvement Aucune — —
2.5.7 Mouvements de glissement Slider : boutons précédent/suivant et points ; le glissement tactile est facultatif TestimonialsSlider §11.2.8
2.5.8 Taille de la cible (minimum) ≥ 24 × 24 px partout, ≥ 44 × 44 px pour les cibles principales Global §5.12.17
3.1.1 Langue de la page <html lang="fr"> Layout §11.2.10
3.1.2 Langue d'un passage Aucun passage non français ; noms de marque exemptés — §11.2.10
3.2.1 Au focus Aucun changement de contexte au focus Global —
3.2.2 À la saisie Aucun changement de contexte à la saisie ; sélection de situation = mise à jour de contenu annoncée ChoiceChip, Select §6.6
3.2.3 Navigation cohérente Même ordre de liens sur toutes les pages Header, Footer §6.1
3.2.4 Identification cohérente Mêmes libellés pour mêmes fonctions (« Faire le point », « Écrire sur WhatsApp », « Écrire via le formulaire ») CTA §7
3.2.6 Aide cohérente Les deux modes de contact (WhatsApp d'abord, formulaire ensuite) apparaissent dans le même ordre dans Contact, dans les offres, dans le résultat de l'orientation et dans la carte du footer Contact, Footer §6.9, §6.10
3.3.1 Identification des erreurs Erreur textuelle liée au champ FormField §5.12.12
3.3.2 Étiquettes ou instructions Labels visibles, aide, mention « facultatif » FormField §5.12.12
3.3.3 Suggestion après une erreur Les messages indiquent la correction attendue (messages en §9.4/§7) FormField §9.4
3.3.4 Prévention des erreurs (juridiques, financières) Non applicable : aucun engagement juridique/financier ni suppression de données n'est soumis ContactForm —
3.3.7 Saisie redondante Aucune information demandée deux fois ; la situation choisie dans l'orientation préremplit le champ facultatif « Votre situation » du formulaire (mémoire de la page, jamais l'URL) ContactForm, Orientation §6.6, §6.9
3.3.8 Authentification accessible (minimum) Aucune authentification ; ni CAPTCHA ni test cognitif (honeypot invisible) ContactForm §9.6
4.1.2 Nom, rôle, valeur Éléments natifs d'abord ; ARIA seulement si nécessaire ; états exposés (aria-selected, aria-expanded, aria-current, aria-invalid) Toutes §11.2.5
4.1.3 Messages d'état Régions vivantes pour envoi, succès, erreur, résultat de la boussole ContactForm, Orientation, Slider §11.2.7

11.2.3 Repères (landmarks) et squelette du document #

<html lang="fr">
<body>
  <a class="skip-link" href="#contenu">Aller au contenu</a>
  <header class="site-header" data-state="top">             <!-- repère banner -->
    <a href="#accueil" aria-label="Malka Debleds — retour à l’accueil">Malka Debleds</a>
    <nav aria-label="Navigation principale"> … </nav>
    <button aria-expanded="false" aria-controls="menu-mobile" aria-haspopup="dialog">…</button>   <!-- sous 1280 px -->
  </header>
  <div id="menu-mobile" role="dialog" aria-modal="true" aria-label="Menu principal"> <nav aria-label="Navigation principale"> … </nav> </div>   <!-- rendu seulement à l'ouverture -->
  <main id="contenu" tabindex="-1">
    <section id="accueil" aria-labelledby="accueil-titre" tabindex="-1"> <h1 id="accueil-titre">…</h1> … </section>
    <section id="philosophie" aria-labelledby="philosophie-titre" tabindex="-1"> … </section>
    …
    <section id="contact" aria-labelledby="contact-titre" tabindex="-1"> … </section>
  </main>
  <footer>                                                   <!-- repère contentinfo -->
    <nav aria-label="Navigation du pied de page"> … </nav>
  </footer>
</body>
Repère Règle
banner Un seul <header> de premier niveau.
navigation Deux navigations maximum au même moment : « Navigation principale » (en-tête, ou copie dans le menu mobile — une seule visible/accessible à la fois : la copie du menu n'existe dans l'arbre d'accessibilité que lorsqu'il est ouvert) et « Navigation du pied de page ». Libellés distincts.
main Un seul, id="contenu", tabindex="-1".
region Chaque <section> a aria-labelledby vers son titre ; la section est donc un repère nommé.
contentinfo Un seul <footer> de premier niveau.
form Le formulaire est nommé (aria-labelledby vers son titre).
Identifiants Les ancres et identifiants de section sont ceux de §4.2 ; les identifiants de titres (<id>-titre) sont uniques.

11.2.4 Ordre de tabulation #

Aucun tabindex positif. Ordre global sur / (bureau) :

  1. Lien d'évitement (visible au focus) ;
  2. Logo (→ #accueil) ;
  3. Liens de navigation (Philosophie, Méthode, Accompagnements, Orientation, À propos, Témoignages si présent, Contact) ;
  4. CTA « Faire le point » (dans la barre dès 640 px ; sous 640 px, dans le menu mobile) ;
  5. Hero : « Prendre rendez-vous », « Découvrir la méthode », puis liens de la barre de méthode (Écouter, Explorer, Clarifier, Décider) ;
  6. Philosophie (liens éventuels) ;
  7. Méthode : onglet sélectionné, puis panneau ;
  8. Accompagnements : par offre, résumé du détail, bouton « Écrire sur WhatsApp » puis lien « Écrire via le formulaire » ;
  9. Orientation : groupe de situations (un seul arrêt), puis les deux CTA du résultat (« Écrire sur WhatsApp », « Écrire via le formulaire ») ;
  10. À propos (liens éventuels) ;
  11. Témoignages : bouton précédent, points de navigation, bouton suivant ;
  12. Contact : bouton WhatsApp, champs dans l'ordre visuel, case de consentement, bouton d'envoi ;
  13. Footer : carte CTA (bouton « Écrire sur WhatsApp », lien « Écrire via le formulaire »), liens de navigation, coordonnées, liens légaux, « Retour en haut ».

Sous 1280 px, les liens de navigation (élément 3) sont remplacés par le bouton « Ouvrir le menu » (ordre : logo, CTA « Faire le point » à partir de 640 px, bouton de menu). Les éléments décoratifs (calques, mot géant, halos) ne sont jamais focalisables.

11.2.5 Cartes clavier par composant #

Composant Touche Effet
Lien d'évitement Entrée Déplace le focus sur main#contenu
NavLink / Button / IconButton Entrée (lien et bouton), Espace (bouton) Active
Bouton « Ouvrir le menu » Entrée, Espace Ouvre le menu, focus sur « Fermer le menu »
Menu mobile (dialogue) Tab / Maj+Tab Parcourt le panneau (piège de focus, §5.12.15)
Échap Ferme et rend le focus au bouton d'ouverture
Boussole de méthode (onglets) Tab Entre sur l'onglet sélectionné (un seul arrêt) ; un second Tab va au panneau (focalisable si aucun contrôle interne)
→ / ↓ Onglet suivant (sélection automatique, boucle de Décider vers Écouter)
← / ↑ Onglet précédent (boucle inverse)
Début / Fin Premier / dernier onglet
Boussole d'orientation (boutons radio) Tab Entre sur la situation cochée ou la première
→ ↓ / ← ↑ Déplace et coche la situation suivante / précédente
Espace Coche la situation focalisée
Slider de témoignages Tab Atteint « Témoignage précédent », les points, puis « Témoignage suivant »
Entrée / Espace Change de témoignage (le focus reste sur le bouton)
Formulaire Tab / Maj+Tab Ordre visuel des champs
Entrée dans un champ texte Soumet le formulaire (comportement natif) ; dans le textarea, insère un retour à la ligne
Espace sur la case Coche / décoche
Select natif Touches natives du système (flèches, initiales)
Carte CTA du footer Tab Bouton « Écrire sur WhatsApp », puis lien « Écrire via le formulaire »

11.2.6 Gestion du focus #

Situation Règle
Navigation par ancre (clic ou clavier) Le navigateur fait défiler jusqu'à la section (défilement doux désactivé en mouvement réduit) ; un petit îlot client écoute hashchange et le hash initial, puis appelle section.focus({ preventScroll: true }) (les <section> ont tabindex="-1"). Aucun anneau n'est dessiné sur les sections elles-mêmes (non interactives).
Lien du logo Retour en haut de #accueil, focus sur la section. Sur les pages légales : navigation vers /.
Menu mobile Ouverture : focus sur « Fermer le menu ». Fermeture : focus rendu au bouton d'ouverture, sauf si la fermeture résulte d'un clic sur une ancre (focus alors sur la section cible).
Focus non masqué par le header (2.4.11) html { scroll-padding-top: calc(var(--header-h) + 1rem) } ; scroll-margin-top identique sur les sections ; le header est le seul élément fixed/sticky hors panneau du menu mobile ; sa hauteur est constante (72/64 px). Aucun élément focalisable ne peut se retrouver sous le header après un Tab.
Carte CTA du footer Positionnement relatif (jamais fixe), donc jamais au-dessus d'un élément focalisé.
Validation du formulaire en erreur À l'échec de la soumission, le focus est placé sur le titre du récapitulatif d'erreurs (§6.9), qui n'a pas de rôle vivant (une seule annonce) ; chaque entrée est un lien qui place le focus sur le champ concerné ; chaque champ invalide porte aria-invalid et aria-describedby vers son message. En dehors de la soumission, un champ n'est validé à la sortie (blur) que s'il a été modifié, sans déplacer le focus.
Erreur serveur ou réseau La bannière d'erreur (Alert en mode focus, §5.12.13) reçoit le focus ; elle n'a pas de rôle vivant (évite la double annonce) ; les liens de secours (WhatsApp, e-mail) sont focalisables.
Succès de l'envoi Le formulaire est remplacé par la confirmation ; le focus est placé sur ce panneau (tabindex="-1", nom accessible = son titre).
Boussole de méthode Le changement d'onglet déplace le focus sur l'onglet, jamais sur le panneau.
Boussole d'orientation La sélection ne déplace pas le focus ; le résultat est annoncé (§11.2.7) et n'est amené dans la vue par défilement que lors d'une sélection au pointeur, jamais au clavier. Les CTA du résultat ne reçoivent le focus que par Tab.
Slider Le focus reste sur le bouton utilisé.
Disparition d'un élément focalisé Si un contrôle focalisé disparaît (ex. bouton d'envoi remplacé par la confirmation), le focus est explicitement déplacé vers l'élément qui le remplace.

11.2.7 Régions vivantes #

Les conteneurs vivants existent dans le DOM dès le chargement et sont vides ; on y injecte du texte (jamais de display: none ↔ visible sur le conteneur lui-même).

Région Attributs Contenu annoncé
Statut d'envoi du formulaire role="status" aria-live="polite" aria-atomic="true" « Envoi en cours… » au début de l'envoi (texte en §7)
Erreur du formulaire Pas de rôle vivant : le récapitulatif d'erreurs (validation) ou la bannière (erreur serveur/réseau) reçoit le focus Titre puis contenu, lus une seule fois
Succès du formulaire Pas de rôle vivant : le focus est déplacé sur le panneau de confirmation Titre + texte de confirmation
Résultat de la boussole d'orientation aria-live="polite" aria-atomic="false" Titre du parcours de réflexion puis texte
Slider de témoignages aria-live="polite" appliqué après la première interaction (au chargement : off) Diapositive courante
Compteur de caractères aucune —
Lien d'évitement, navigation active aucune (l'état actif est porté par aria-current) —

Une action = une annonce. Un message ne reste jamais seulement dans un élément éphémère : le texte d'erreur reste affiché tant que la condition persiste.

11.2.8 Slider de témoignages : 2.5.7, 2.5.1, 2.2.2 #

Le contrat complet (structure, contrôles, mouvement) est en §6.8 ; les exigences d'accessibilité sont :

  • Structure : conteneur <section aria-roledescription="carrousel" aria-label="Témoignages"> ; chaque diapositive role="group" aria-roledescription="témoignage" aria-label="{i} sur {n}". Les valeurs de aria-roledescription sont en français (la page est lang="fr"). Toutes les diapositives restent montées dans une même cellule de grille ; les diapositives non courantes sont inert et visibility: hidden.
  • Aucune lecture automatique (pas d'autoplay) : 2.2.2 est satisfait par construction. Le slider boucle (le bouton « suivant » de la dernière diapositive revient à la première) : les boutons ne sont jamais désactivés.
  • Alternative au glissement (2.5.7) : boutons « Témoignage précédent » et « Témoignage suivant » (IconButton 48 × 48) et points de navigation (10 px visibles, zone de clic de 44 × 44 px) toujours présents lorsqu'il y a au moins deux témoignages publiables ; le glissement tactile ou à la souris est un confort facultatif, jamais la seule voie. Aucun geste multipoint ni tracé (2.5.1).
  • Région vivante : aria-live="off" au chargement, polite après la première interaction de l'utilisateur (§11.2.7).
  • Les guillemets français « » sont ajoutés par le composant autour du texte, qui reste intact (§5.12.11).
  • Sans JavaScript (@media (scripting: none)), toutes les citations sont listées verticalement et les contrôles sont masqués.
  • S'il n'y a qu'un témoignage publiable : rendu statique par un composant serveur, sans contrôles, sans aria-roledescription. S'il n'y en a aucun : la section, le lien de navigation et le lien du menu mobile sont absents (voir §6.8). Un témoignage est publiable si published et consentement accordé et citation non vide (700 caractères au plus).
  • Mouvement réduit : le changement de diapositive est un fondu enchaîné instantané (≤ 0,2 s) ; aucun défilement animé (voir §8).

11.2.9 Mouvement, animations et préférences #

Sujet Règle
prefers-reduced-motion: reduce MotionConfig reducedMotion="user" ; parallaxe, translations, mises à l'échelle, animations d'ambiance, comptage et défilement doux désactivés ; apparitions en fondu ≤ 0,2 s ou immédiates (détail et variantes en §8).
2.2.2 Pause, arrêt, masquage Toute animation d'ambiance qui démarre sans action de l'utilisateur (dérive de halos, brume, scintillement de rivière) exécute un seul cycle d'au plus 5 s (animation-iteration-count: 1) à chaque entrée de l'élément dans le viewport, avec un état final identique à l'état de repos (cycle symétrique) ; elle est suspendue quand l'élément est hors du viewport ou l'onglet masqué, et ne redémarre qu'après une sortie puis une nouvelle entrée. Elle est désactivée sur mobile et en mouvement réduit. Les mouvements liés au défilement (parallaxe) sont déclenchés par l'utilisateur et ne sont pas concernés. Les séquences d'entrée (hero ≤ 2,2 s) et le comptage des chiffres (≤ 2 s) sont bornés.
2.3.1 Flashs Aucun contenu ne clignote ; aucune variation de luminosité rapide.
Indicateur de chargement La rotation de l'icône LoaderCircle est un retour d'état essentiel ; en mouvement réduit elle ne tourne pas (le libellé « Envoi en cours… » porte l'information).
Mobile Effets lourds simplifiés (parallaxe désactivée sous 1024 px ou en pointeur grossier) : voir §8.
Contraste élevé (forced-colors) Anneaux de focus en outline, icônes en currentColor, bordures conservées ; le mot géant et le grain sont masqués.
Contraste renforcé (prefers-contrast: more) Bordures plus marquées, grain supprimé (§5.11).

11.2.10 Noms accessibles, libellés techniques et langue #

<html lang="fr">. Aucune portion non française au lancement ; les noms propres et de marque (« WhatsApp », « Steff Beauté Plurielle ») sont exemptés de lang (critère 3.1.2). Si un passage anglais était ajouté ultérieurement, il porterait lang="en".

Les libellés techniques ci-dessous sont centralisés dans src/lib/a11y/labels.ts (A11Y_LABELS_FR). Les textes éditoriaux et les messages de formulaire sont en §7 et §9.4.

Clé Libellé français
skipLink Aller au contenu
logo Malka Debleds — retour à l’accueil
mainNav Navigation principale
footerNav Navigation du pied de page
openMenu Ouvrir le menu
closeMenu Fermer le menu
menuDialog Menu principal (nom accessible du menu mobile)
plusDe Plus de
opensNewTab (ouvre dans un nouvel onglet)
opensWhatsapp (ouvre WhatsApp dans un nouvel onglet)
methodTablist Les quatre temps de la méthode
heroRail Les quatre temps de la méthode
carouselLabel Témoignages
carouselRole carrousel
slideRole témoignage
slideLabel(i, n) {i} sur {n}
prevTestimonial Témoignage précédent
nextTestimonial Témoignage suivant
euros(n) {n} euros (texte masqué des prix)

Règles de prononciation et de lecture en français :

  1. « CLARTÉ » géant : aria-hidden="true", hors de l'arbre d'accessibilité.
  2. Téléphone : lien tel:+33667295179 dont le texte affiché est « 06 67 29 51 79 » avec des espaces simples entre les paires de chiffres (lecture par paires) ; le lien porte white-space: nowrap pour ne jamais être coupé ; aucun aria-label qui remplacerait le texte visible.
  3. Prix : le montant mis en forme (1 000 €) est aria-hidden="true" et un VisuallyHidden porte « 1000 euros » (évite la lecture « 1 » « 000 » selon la synthèse vocale) ; « Sur devis » est lu tel quel.
  4. Chiffres de confiance : « Plus de 300 personnes accompagnées » (§5.12.7).
  5. Espaces fines insécables (U+202F) : silencieuses dans les principaux lecteurs d'écran ; en cas d'annonce de « espace insécable fine » constatée avec une combinaison précise lecteur/synthèse, la correction consiste à remplacer U+202F par U+00A0 dans frTypo pour la ponctuation (modification unique, centralisée).
  6. Ellipse : le caractère … est lu « points de suspension » ; conservé.
  7. Citations : le texte du témoignage est dans <blockquote> ; les guillemets sont du texte (lus ou ignorés selon le lecteur) et ne nuisent pas à la compréhension.
  8. Adresse e-mail : mailto: avec texte visible = adresse ; overflow-wrap: anywhere.
  9. Noms accessibles des liens ouvrant un nouvel onglet : texte visible + VisuallyHidden « (ouvre dans un nouvel onglet) » en fin de nom.

11.2.11 Zoom, reflow, espacement et préférences d'affichage #

Critère Exigence testée
1.4.4 Zoom 200 % À 1280 × 800 avec zoom navigateur 200 % (viewport utile 640 px) : aucune perte de contenu ni de fonction, aucun chevauchement.
1.4.10 Reflow / zoom 400 % À 320 px de large (équivalent 400 % sur 1280 px) : aucun défilement horizontal, aucun contenu masqué sauf les éléments purement décoratifs ; titres, CTA, formulaire et navigation restent utilisables.
Texte seul agrandi Réglage navigateur « taille de police » à 200 % : tailles en rem, aucun texte rogné.
1.4.12 Espacement du texte Avec line-height: 1.5, espacement de paragraphe 2×, letter-spacing: 0.12em, word-spacing: 0.16em appliqués par injection de CSS : aucun texte rogné ni superposé. Les masques de titres ont une marge de sécurité de 0,12 em en haut et en bas et aucune hauteur fixe.
Paysage mobile Contenu utilisable à 568 × 320 (hero adapté, §6.2) ; menu mobile défilable.
1.3.4 Orientation Aucun verrouillage.
Cibles tactiles §5.12.17.

11.2.12 Tests d'accessibilité et critères d'acceptation #

États à scanner avec axe (chaque état × 3 largeurs : 390, 768, 1440 px ; le menu mobile n'est scanné qu'à 390 et 768 px, où il existe) :

  1. / au chargement ; 2. header scrolled ; 3. menu mobile ouvert ; 4. méthode, chaque onglet sélectionné (4) ; 5. orientation, chaque situation (4) ; 6. formulaire vide, avec erreurs de validation, en envoi, en succès, en erreur serveur (réponse simulée) ; 7. section Témoignages présente (données de test avec 2 témoignages publiables) et absente ; 8. pages légales (avec et sans bandeau de développement) ; 9. 404.

Protocole manuel (avant mise en production) :

# Vérification Outils
1 Parcours complet au clavier seul (sans souris) de la page, formulaire inclus, ordre §11.2.4 Chrome, Firefox, Safari
2 Lien d'évitement, focus toujours visible et jamais masqué par le header Clavier
3 Lecture de la page par repères et par titres, plan §11.1.8 NVDA + Firefox, VoiceOver + Safari (macOS)
4 Menu mobile, boussole de méthode, boussole d'orientation, slider : états et annonces VoiceOver + Safari (iOS), TalkBack + Chrome (Android)
5 Formulaire : erreurs, envoi, succès, erreur serveur annoncés une seule fois NVDA, VoiceOver
6 Zoom 200 % et 400 %, texte agrandi, espacement du texte Navigateur
7 Mouvement réduit activé au niveau système : aucun mouvement, contenu toujours présent macOS/Windows/Android
8 Mode contraste élevé Windows (forced-colors) : focus et contrôles visibles Windows
9 Prononciation du téléphone, des prix, de « Plus de 300 » NVDA, VoiceOver (voix française)
ID Critère d'acceptation Vérification
AC-11.2-01 axe : 0 violation d'impact serious ou critical (balises wcag2a/aa/21a/21aa/22aa) dans tous les états listés ; les violations moderate/minor sont consignées dans docs/a11y-report.md. Playwright + axe
AC-11.2-02 Lighthouse Accessibilité = 100 (mobile) sur /, /mentions-legales, /politique-de-confidentialite. Lighthouse CI
AC-11.2-03 Un Tab initial focalise le lien d'évitement ; Entrée place le focus sur main#contenu. E2E
AC-11.2-04 Parcours Tab complet de / : chaque élément focalisé a un anneau visible (contraste ≥ 3 : 1) et son rectangle est entièrement sous le bas du header (aucun masquage) à 390, 768, 1440 px. E2E
AC-11.2-05 Aucun tabindex positif ; aucun élément décoratif focalisable. E2E + lint
AC-11.2-06 Le menu mobile : Échap ferme, le focus revient au bouton, le défilement de page est verrouillé, la fermeture est automatique à ≥ 1280 px. E2E
AC-11.2-07 Boussole de méthode : flèches/Début/Fin changent d'onglet, un seul onglet a tabindex="0", aria-selected correct, aria-controls valides. E2E
AC-11.2-08 Boussole d'orientation : radios natifs, flèches fonctionnelles, résultat annoncé par la région polite, focus non déplacé. E2E
AC-11.2-09 Slider : conteneur aria-roledescription="carrousel", diapositives role="group" aria-roledescription="témoignage" ; boutons précédent/suivant (48 px, jamais désactivés, bouclage) et points (zone ≥ 44 px) actifs au clavier ; diapositives non courantes inert ; région vivante off avant la première interaction puis polite ; aucun autoplay, aucune dépendance au glissement ; avec 1 témoignage : citation statique sans contrôles ; avec 0 : section et liens absents ; sans JavaScript : toutes les citations listées. E2E avec trois jeux de données
AC-11.2-10 Formulaire : toutes les erreurs ont aria-invalid + aria-describedby ; à l'échec de la soumission, le récapitulatif d'erreurs reçoit le focus et chacun de ses liens place le focus sur le champ concerné ; succès → focus sur le panneau de confirmation ; erreur serveur → la bannière reçoit le focus (une seule annonce, aucun rôle vivant) ; pendant l'envoi, aria-busy="true", champs en lecture seule et focus conservé ; honeypot hors arbre d'accessibilité. E2E (réponses simulées)
AC-11.2-11 Cibles : toute cible interactive ≥ 24 × 24 px ; boutons, liens de navigation, chips, champs ≥ 44 px de zone de clic. E2E (mesure)
AC-11.2-12 À 320 px de large, document.documentElement.scrollWidth <= clientWidth sur toutes les routes ; avec l'espacement de texte WCAG injecté, aucun conteneur de texte n'a scrollHeight > clientHeight + 1. E2E
AC-11.2-13 prefers-reduced-motion: reduce : aucune animation CSS/JS de translation, d'échelle ou de boucle (mesure par getAnimations() vide hors fondus ≤ 0,2 s) ; tout le contenu est visible. E2E avec reducedMotion: 'reduce'
AC-11.2-14 Aucune animation d'ambiance ne dure plus de 5 s ni ne se répète sans nouvelle entrée dans le viewport (mesure des animations en cours à t = 6 s : aucune ; iterationCount = 1). E2E
AC-11.2-15 « CLARTÉ » et tous les calques décoratifs sont aria-hidden="true" ; les images décoratives ont alt="" ; les portraits ont un alt non vide sans « photo de ». E2E
AC-11.2-16 Le numéro de téléphone est un lien tel: à espaces simples avec white-space: nowrap, les prix ont un équivalent masqué « N euros », les chiffres de confiance se lisent « Plus de 300 … » / « Plus de 50 … ». Test de composant
AC-11.2-17 Le nom accessible de chaque bouton/lien contient son texte visible (2.5.3). axe (label-content-name-mismatch) + test
AC-11.2-18 Le viewport permet le zoom ; aucune mise en page ne se casse à 200 % de zoom. E2E
AC-11.2-19 Protocole manuel §11.2.12 exécuté et consigné dans docs/a11y-report.md (fichier créé par l'équipe de développement) avant la mise en production. Revue

11.3 Performance #

11.3.1 Objectifs et budgets #

Cadre de mesure : Lighthouse en émulation mobile (réseau 4G lent simulé, processeur ralenti) ; données de terrain (CrUX) au 75ᵉ centile.

Indicateur Budget Mesure
Lighthouse Performance (mobile) ≥ 90 CI, médiane de 3 exécutions
Lighthouse Accessibilité 100 CI
Lighthouse Bonnes pratiques ≥ 95 CI
Lighthouse SEO 100 (build de CI, audit is-crawlable exclu ; l'indexabilité de la production est contrôlée au déploiement, §11.3.11) CI
LCP ≤ 2,5 s Laboratoire + terrain p75
CLS ≤ 0,05 Laboratoire + terrain p75
INP ≤ 200 ms Terrain p75 ; en laboratoire : TBT ≤ 200 ms + test d'interaction (§11.3.9)
JS de premier chargement de / ≤ 170 Ko gzip (hors analytique optionnelle) Test Playwright + Lighthouse CI
CSS de premier chargement ≤ 35 Ko gzip Test Playwright + Lighthouse CI
Images au-dessus de la ligne de flottaison (mobile, AVIF) ≤ 350 Ko Test Playwright + Lighthouse CI
Polices préchargées (woff2) ≤ 200 Ko cumulés Test Playwright
HTML (document) ≤ 60 Ko gzip Test Playwright
Transfert total à froid sur mobile (premier écran) ≤ 900 Ko Lighthouse CI
Nœuds DOM ≤ 1 200 sur / Lighthouse CI
Domaines tiers 0 (1 si l'analytique Umami est activée) Lighthouse CI

Tout dépassement fait échouer l'intégration continue. Une modification de budget exige la modification de ce tableau.

11.3.2 Architecture de rendu #

  1. /, /mentions-legales, /politique-de-confidentialite sont générées statiquement (export const dynamic = 'force-static' sur les pages). Aucune donnée n'est chargée à l'exécution : le contenu est compilé à partir de src/content/fr (§9.3).
  2. Composants serveur par défaut. 'use client' n'est ajouté qu'aux îlots de la liste fermée de §9.2.3 (fichiers marqués (C) en §12.2), au plus bas possible dans l'arbre, avec des props sérialisables minimales (on ne passe jamais l'objet de contenu complet à un composant client).
  3. Aucun appel réseau côté client au chargement ; la seule requête applicative est le POST /api/contact à l'envoi du formulaire.
  4. Les routes API sont dynamiques et ne participent pas au premier chargement.

11.3.3 Pipeline d'images #

Toute image raster passe par next/image (avec sharp à l'exécution, présent dans la sortie standalone) : Malka fournit un seul master par emplacement (§5.9.2, §12.10) et Next.js produit à la demande les largeurs AVIF/WebP. Il n'existe ni variantes préparées, ni manifeste, ni composant d'image maison. Configuration next.config.ts (identique à celle de §12.10.3) :

images: {
  formats: ['image/avif', 'image/webp'],               // AVIF d'abord, WebP ensuite, original en dernier recours
  deviceSizes: [640, 828, 1080, 1280, 1920, 2560],
  qualities: [60, 75],                                  // 60 paysages, 75 portraits
  localPatterns: [{ pathname: '/images/**' }],          // rien en dehors de public/images n'est optimisé ; aucune image distante
},

Les SVG de développement (dev-fallback, logo, filets) sont rendus avec unoptimized ; dangerouslyAllowSVG reste à sa valeur par défaut (faux). L'encodage AVIF à la demande est gourmand en mémoire : le conteneur web dispose de mem_limit: 768m (§14.4) et le cache d'optimisation (/app/.next/cache) est un volume temporaire (voir §14). Le cache de /_next/image reprend le Cache-Control du fichier source (un an, §11.3.8), ce qui est sûr car tout fichier remplacé change de nom (§5.9.2).

Règles par emplacement (les dimensions des masters sont en §5.9.2) :

Slot Rendu sizes Qualité Chargement Espace réservé Budget (AVIF)
hero-far (≥ 1024 px) <picture> via getImageProps, fill, alt="" ; <source media="(min-width:1024px)"> + <img> de repli = pixel transparent 1×1 en data URI (rien n'est téléchargé en dessous de 1024 px) 100vw 60 fetchPriority="high", préchargement limité à media="(min-width:1024px)" (seul calque prioritaire du site) couleur de fond du conteneur (forest-950) ≤ 260 Ko à 1920 px
hero-mid, hero-near (≥ 1024 px) <picture> identique : <source media="(min-width:1024px)"> + pixel transparent de repli 100vw 60 eager, fetchPriority="low", decoding="async", sans préchargement aucun (alpha) ≤ 180 Ko chacune à 1920 px
hero-mobile (< 1024 px, dans la bande encadrée uniquement) <picture> : <source media="(max-width:1023.98px)"> + pixel transparent de repli ; fill, object-fit: cover (min-width: 768px) 688px, calc(100vw - 48px) 60 eager, fetchPriority="auto", sans préchargement (l'élément LCP mobile est le titre, pas l'image) couleur de fond du conteneur (forest-950) ≤ 150 Ko à 828 px (≤ 200 Ko à 1080 px)
hero-portrait next/image (min-width: 1024px) 36vw, 82vw 75 eager, fetchPriority="auto", sans préchargement aucun (alpha) ≤ 90 Ko à 720 px ; ≤ 180 Ko à 1200 px
about-portrait next/image (min-width: 1024px) 40vw, 90vw 75 lazy dégradé ivory-200 → sage-200 du conteneur ≤ 120 Ko à 800 px
og-default fichier statique — — — — ≤ 300 Ko (JPEG)

Le décor du pied de page est vectoriel (SVG en ligne, §6.10) : il n'a aucune ligne dans ce tableau. Somme mobile au-dessus de la ligne de flottaison : hero-mobile (≤ 200 Ko) + hero-portrait (≤ 90 Ko) = ≤ 290 Ko, sous le budget de 350 Ko. Sur desktop, les trois calques + portrait restent ≤ 800 Ko.

Code de référence du calque lointain (composant serveur) :

// src/components/sections/hero/hero-far-layer.tsx
import { getImageProps } from 'next/image';

const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';

export function HeroFarLayer({ src }: { src: string }) {
  const { props } = getImageProps({
    alt: '', fill: true, sizes: '100vw', quality: 60, src,
    unoptimized: src.endsWith('.svg'),              // secours de développement
  });
  const srcSet = props.srcSet ?? props.src;
  return (
    <>
      {/* Le préchargement reproduit exactement srcset/sizes de l'image : aucun double téléchargement */}
      <link rel="preload" as="image" media="(min-width: 1024px)" imageSrcSet={srcSet} imageSizes="100vw" fetchPriority="high" />
      <picture aria-hidden="true">
        <source media="(min-width: 1024px)" srcSet={srcSet} sizes="100vw" />
        <img
          src={TRANSPARENT_PIXEL} alt="" width={1} height={1} style={props.style}
          data-lcp="hero-landscape" fetchPriority="high" loading="eager" decoding="async"
          className="hero-layer hero-layer--far"
        />
      </picture>
    </>
  );
}
  • Les composants du hero qui rendent un <picture> à partir de getImageProps (hero-far-layer.tsx et hero-landscape.tsx, §12.2) sont les seules exceptions à l'interdiction de <img> brut : la règle @next/next/no-img-element y est désactivée par un commentaire justifié (les attributs de l'<img> proviennent de getImageProps). Les calques hero-mid, hero-near et la bande hero-mobile suivent le même schéma que hero-far sans préchargement ni data-lcp propre (la bande porte data-lcp="hero-band").
  • Aucun flou de substitution (placeholder="blur") : les conteneurs ont la couleur de fond du slot (forest-950 pour le hero ; dégradé ivory-200 → sage-200 pour le portrait « À propos »), ce qui évite tout rectangle clair et tout calcul au build.
  • Pour les images de développement dev-fallback (SVG) : unoptimized.
  • Le conteneur de chaque image a un aspect-ratio ou une taille explicite (§11.3.7).
  • L'élément LCP n'est pas figé : sur desktop c'est le calque lointain (data-lcp="hero-landscape") ou le <h1> ; en dessous de 1024 px c'est le <h1> (l'image de la bande porte data-lcp="hero-band"). Le test de performance accepte [data-lcp] ou le <h1>, exige un LCP ≤ 2,5 s et vérifie que l'élément n'a jamais opacity: 0.
  • Préchauffage du cache d'optimisation : le cache d'images de Next.js (/app/.next/cache) est un volume temporaire (voir §14) : après chaque redémarrage du conteneur, la première requête de chaque variante déclenche un encodage AVIF coûteux sur le processeur du VPS. Le script scripts/warm-cache.sh, exécuté par l'opérateur après la vérification de santé de chaque déploiement (étape du runbook de mise en production, §14), précharge toutes les variantes référencées par la page d'accueil :
#!/usr/bin/env sh
# scripts/warm-cache.sh — usage : scripts/warm-cache.sh https://exemple.fr
set -eu
BASE="${1:?usage: warm-cache.sh <origine>}"
HTML="$(curl -fsS "$BASE/")"
printf '%s' "$HTML" | grep -o '/_next/image?[^" ,]*' | sed 's/&amp;/\&/g' | sort -u | while read -r path; do
  curl -fsS -H 'Accept: image/avif,image/webp,*/*' -o /dev/null "$BASE$path" || echo "warm-cache: échec $path" >&2
done

11.3.4 Polices #

  • Déclaration : next/font/google (voir §5.5.1) ; fichiers auto-hébergés à la compilation, servis depuis /_next/static/media/ ; aucune requête vers un domaine de polices tiers à l'exécution.
  • Deux familles, trois fichiers : Fraunces romain (variable opsz + wght), Fraunces italique (seconde instance next/font, preload: false, variable --font-display-italic) et Manrope (variable wght). Manrope n'a pas d'italique et font-synthesis: none est posé sur html (§5.11) : aucun faux italique n'est fabriqué.
  • display: 'swap' ; adjustFontFallback: true : la police de repli reçoit des métriques ajustées (size-adjust, ascent-override, etc.) pour réduire le décalage au remplacement. L'objectif de CLS lié aux polices est ≤ 0,01.
  • Préchargement : uniquement les fichiers woff2 des deux familles de base (Fraunces romain et Manrope), générés automatiquement par next/font dans <head>. Le sous-ensemble latin-ext est conservé par décision (§5.5.1). Contrôle : au plus 4 balises <link rel="preload" as="font" type="font/woff2" crossorigin> (latin + latin-ext × 2 familles), aucune pour l'italique (non préchargée) ni pour une police tierce, cumul préchargé ≤ 200 Ko. Le fichier italique n'est téléchargé que lorsqu'un em de titre ou une citation d'accroche est rendu ; son poids est consigné dans docs/perf-report.md et le CLS mesuré inclut son chargement. Si le cumul préchargé dépasse 200 Ko au premier build, supprimer latin-ext des déclarations de src/app/fonts.ts (le français n'en dépend pas) et consigner la mesure dans docs/perf-report.md.
  • Les polices sont déclarées dans le layout racine (une seule fois) ; aucun @font-face manuel.
  • Les chiffres tabulaires (tabular-nums) évitent les sauts de largeur sur les chiffres de confiance et prix.

11.3.5 JavaScript : fractionnement et îlots clients #

Règle : tout ce qui n'a pas besoin d'état, d'effet ou d'API navigateur est un composant serveur. La liste fermée des îlots clients, leur raison et leur rattachement à un fichier sont définis une seule fois, en §9.2.3 (fichiers marqués (C) en §12.2) ; elle n'est pas répétée ici. Du point de vue des performances :

  • Hydratation immédiate : menu et navigation du header, boussole de méthode, boussole d'orientation, formulaire de contact. Hydratation après idle : mouvement du hero, primitives de révélation, compteurs, décor du pied de page.
  • Le slider de témoignages n'est rendu que s'il y a au moins deux témoignages publiables ; il est chargé par next/dynamic avec rendu serveur conservé.
  • Le fournisseur de mouvement (LazyMotion + MotionConfig reducedMotion="user") charge domAnimation de façon synchrone avec lui (§8.2.3).

Tout le reste (sections éditoriales, OfferItem, SectionHeading, FloatingCtaCard, pages légales, pied de page) est rendu côté serveur.

Règles :

  1. m.* (Motion) uniquement ; interdiction d'importer motion.* hors du fournisseur ; domAnimation suffit (voir §8).
  2. Icônes lucide-react importées nommément (arborescence élaguée) ; aucun import de l'index complet.
  3. Le schéma Zod partagé est importé dans l'îlot du formulaire seulement ; aucun autre îlot ne l'importe. Aucun îlot n'importe getContent, src/content/** ni src/lib/booking/index.ts : les textes et les href (dont WhatsApp) arrivent en props depuis les composants serveur.
  4. Pas de bibliothèque utilitaire lourde (dates, lodash, animation concurrente).
  5. Les scripts next/script : aucun, sauf Umami (§11.3.10).

Répartition du budget de 170 Ko gzip (première charge de /) :

Poste Budget
Exécution Next.js + React (routeur, hydratation) ≤ 110 Ko
Motion (LazyMotion + domAnimation chargés au premier écran) ≤ 22 Ko
Code applicatif des îlots ≤ 26 Ko
Zod + logique du formulaire ≤ 8 Ko
Icônes ≤ 2 Ko
Marge 2 Ko

La mesure réelle est consignée dans docs/perf-report.md à la première intégration. Si la ligne de base de l'exécution Next.js dépasse 110 Ko avec la version installée (§9.1), le poste « code applicatif » est réduit en priorité (chargement différé des îlots non critiques) — le total de 170 Ko ne change pas.

11.3.6 CSS #

  1. Une feuille Tailwind unique, purgée par le compilateur (aucun CSS mort), ≤ 35 Ko gzip. Aucun CSS-in-JS d'exécution, aucun @import externe.
  2. Tout CSS animé se limite à transform, opacity, filter (≤ 8 px, desktop) et clip-path (voir §8). Aucune animation de width, height, top, left, margin, box-shadow.
  3. will-change n'est jamais écrit en CSS statique ; Motion le pose et le retire pendant les animations.
  4. Les calques décoratifs ont contain: layout paint et pointer-events: none.
  5. backdrop-filter : uniquement le header scrollé à partir de 768 px (§5.4.3).
  6. content-visibility: auto est interdit sur ce site : il fait varier la hauteur des sections au défilement, perturbe l'ancrage (#contact) et crée des décalages de mise en page pour un gain négligeable sur une page de cette taille.
  7. Pas de grandes zones de filter: blur() fixes ; les halos sont des dégradés radiaux.
  8. Interactions tactiles : touch-action: manipulation sur a, button, [role="tab"], label (supprime le délai de tape, le zoom au pincement reste possible).

11.3.7 Règles anti-décalage de mise en page (CLS ≤ 0,05) #

  1. Toute image a width/height ou un conteneur avec aspect-ratio ; aucun conteneur d'image sans taille intrinsèque.
  2. À partir de 1024 px, le hero utilise min-height: var(--hero-min-h) = clamp(720px, 100svh, 1080px) (unités svh, jamais vh, §5.6.3) : la barre d'adresse mobile ne redimensionne pas le hero. En dessous de 1024 px, le hero n'a pas de hauteur minimale (hauteur du contenu) et la bande d'image encadrée a un aspect-ratio fixe (16:10 de 768 à 1023 px, 9:10 en dessous de 768 px).
  3. Le header a une hauteur constante (72/64 px) dans tous ses états ; seul le fond change.
  4. Polices : métriques de repli ajustées (§11.3.4) ; pas de font-display: optional ; chiffres tabulaires pour les valeurs animées.
  5. Aucune injection de contenu au-dessus du contenu existant au chargement : pas de bandeau de cookies (aucun traceur par défaut), pas de bannière d'annonce.
  6. Le slider empile toutes ses diapositives dans une même cellule de grille (les diapositives inactives sont visibility: hidden et inert) : la hauteur est celle de la plus haute ; le changement de diapositive n'altère pas la hauteur.
  7. La boussole de méthode empile ses quatre panneaux de la même manière (inactifs visibility: hidden et inert) ; le changement d'onglet est un fondu (opacité) sans changement de hauteur.
  8. La boussole d'orientation réserve la zone de résultat (min-height correspondant au résultat le plus haut, calculé en CSS par empilement) ; en tout état de cause, les décalages dans les 500 ms suivant une interaction ne comptent pas dans le CLS, mais la réservation est requise pour éviter tout saut visible.
  9. Les états du formulaire (erreur, envoi, succès) ont leur zone réservée ou apparaissent sous le champ concerné sans repousser un élément déjà lu ; le bouton d'envoi garde sa largeur en état de chargement (§5.12.2).
  10. Les animations d'entrée n'utilisent que transform/opacity : aucune ne modifie la place occupée. Le rendu serveur est visible par défaut (§5.12.6).
  11. Pas de lazy sur une image au-dessus de la ligne de flottaison ; pas d'image sans réservation sous la ligne de flottaison.
  12. scrollbar-gutter: stable sur html : le verrouillage du défilement (html[data-scroll-locked]) à l'ouverture du menu ne décale pas la page.
  13. Les polices ne sont jamais chargées par CSS différé.
  14. Les éléments dont le contenu change (compteur de caractères, chiffres de confiance) ont une largeur minimale en ch.
  15. La carte flottante du pied de page chevauche #contact de --footer-overlap dans une zone réservée (padding-block-end de #contact) : elle ne déplace aucun contenu (§5.12.14).

11.3.8 Mise en cache et en-têtes #

Ressource Cache-Control Mécanisme
/_next/static/* (JS, CSS, polices, médias importés) public, max-age=31536000, immutable Valeur par défaut de Next.js (ne pas la surcharger)
/_next/image?… Reprend celui du fichier source (/images/* : un an) Comportement de next/image : la durée du cache suit l'en-tête du fichier source
/images/*, /textures/* public, max-age=31536000, immutable Règle du constructeur d'en-têtes (ci-dessous) ; tout fichier remplacé change de nom (§5.9.2)
/og/* public, max-age=86400, stale-while-revalidate=604800 Règle du constructeur d'en-têtes
Icônes (/icon.svg, /apple-icon.png) Valeur par défaut de Next.js Fichiers de convention de src/app/
/sitemap.xml, /robots.txt public, max-age=3600 Règle du constructeur d'en-têtes
HTML (/, pages légales, 404) Valeur par défaut de Next.js pour les pages statiques (s-maxage + ETag, réponses 304) ; aucun en-tête n'est ajouté ni forcé par le proxy Aucun cache partagé n'est placé devant le site par défaut ; les navigateurs ignorent s-maxage et revalident
/api/* no-store Règle du constructeur d'en-têtes (réponses des routes, §9.4)

Les règles ci-dessus (comme les en-têtes de sécurité de §10 et les X-Robots-Tag de §11.1.7) sont produites par le constructeur unique de src/lib/security/headers.ts, appelé par headers() dans next.config.ts : aucune règle n'est écrite ailleurs, et le Caddyfile n'ajoute aucun en-tête (TLS et compression uniquement, §14).

Compression : compress: true (gzip) côté Next.js ; le proxy active zstd et gzip (§14). Si un CDN est ajouté ultérieurement, il doit être purgé à chaque déploiement ou configuré pour ne pas mettre en cache le HTML.

11.3.9 Réactivité (INP ≤ 200 ms) #

  1. Aucun gestionnaire d'événement ne dépasse 50 ms de travail synchrone ; tout travail non essentiel est différé (startTransition, requestIdleCallback avec repli setTimeout).
  2. Aucun setState déclenché par le défilement : la section active est calculée par un IntersectionObserver unique (seuils fixes) ; la parallaxe utilise des valeurs de mouvement (useScroll, useTransform), sans re-rendu React.
  3. Les écouteurs scroll, touchstart, touchmove sont passive ; pas de lecture de géométrie (getBoundingClientRect, offsetHeight) suivie d'écriture de style dans la même boucle.
  4. La sélection d'une phase de la méthode ou d'une situation d'orientation ne rend que le sous-arbre concerné (panneaux mémoïsés, key stables).
  5. Le formulaire gère l'état localement, valide à l'envoi et au blur des champs modifiés (§5.12.12) ; la validation Zod d'un champ est synchrone et < 5 ms.
  6. Hydratation échelonnée : les îlots non critiques (§9.2.3 ; mouvement, compteurs, décor du footer : « après idle ») ne s'hydratent pas pendant l'interaction initiale.
  7. Contrôle automatisé : test Playwright avec ralentissement CPU ×4 (CDP) mesurant, via PerformanceObserver de type event, la durée de ces interactions : ouverture du menu, sélection d'un onglet de méthode, sélection d'une situation, saisie dans le message (50 caractères), clic sur « Envoyer » (réponse simulée) ; chaque durée ≤ 200 ms.
  8. TBT Lighthouse ≤ 200 ms.

11.3.10 Services tiers #

  • Aucun par défaut : ni polices externes, ni cartes, ni vidéos intégrées, ni widgets de messagerie (WhatsApp est un simple lien https://wa.me/…), ni agenda en ligne au lancement, ni pixel de suivi, ni CDN d'images.
  • Seule exception, conditionnelle : le script Umami lorsque ANALYTICS_PROVIDER=umami (voir §9.8 et §10) : next/script en afterInteractive, attribut defer, data-do-not-track="true", data-exclude-search="true" et data-exclude-hash="true" (aucun paramètre ni ancre d'URL n'est transmis) ; budget ≤ 3 Ko ; la politique de sécurité du contenu est étendue à cette seule origine ; aucun preconnect ; aucune bibliothèque de mesure des Web Vitals n'est ajoutée.
  • Les ressources externes sont interdites dans le code (test de recherche sur http(s):// dans src/). Liste blanche explicite : liens wa.me, mailto:, tel:, URL de l'API Brevo côté serveur, https://schema.org (contexte JSON-LD), http://www.w3.org/2000/svg (espace de noms SVG), https://www.cnil.fr/fr/plaintes (page de réclamation de la politique de confidentialité) et la valeur de développement http://localhost:3000 des modules de configuration.

11.3.11 Plan de mesure et configuration Lighthouse CI #

Mesures :

  1. Local : make lighthouse (voir §14) lance Lighthouse CI sur l'image construite (port 3000) : trois exécutions mobile (médiane) sur /, /mentions-legales, /politique-de-confidentialite. Pour viser une autre origine (pré-production), la cible lit la variable d'outillage LHCI_BASE_URL (§13) et la passe en --collect.url.
  2. CI : mêmes assertions bloquantes ; test Playwright tests/e2e/perf-budget.spec.ts (§15) qui, sur / en mobile : somme les octets gzip de tous les script (tailles compressées par zlib si l'en-tête content-encoding est absent), des feuilles de style, des polices et des images chargées avant load ; compte les <link rel="preload" as="font"> ; vérifie les en-têtes de cache de §11.3.8 ; cumule le CLS pendant un défilement complet à 390, 768 et 1440 px.
  3. Pré-production : exécution sur un appareil Android d'entrée de gamme en 4G réelle ; résultat consigné dans docs/perf-report.md (fichier créé par l'équipe de développement).
  4. Terrain : après mise en ligne, suivi par PageSpeed Insights / CrUX (données publiques de Chrome). Aucune collecte de Web Vitals côté client : la bibliothèque web-vitals n'est pas installée, y compris lorsque l'analytique est activée (Umami ne mesure pas les Web Vitals).
  5. Régression : tout dépassement de budget bloque la fusion.

Il n'existe qu'un seul fichier de configuration, lighthouserc.json, pour toutes les mesures. La CI mesure un build non indexable (noindex, Disallow: /, §11.1.7) : l'audit is-crawlable est donc retiré du calcul de la catégorie SEO par skipAudits, ce qui permet d'exiger un score SEO de 1 partout. L'indexabilité de la production est vérifiée par le runbook de mise en production (§14) : curl de /robots.txt (sans Disallow: /), absence de X-Robots-Tag et de noindex sur /. La CI mesure avec ANALYTICS_PROVIDER=none (aucun domaine tiers) ; le budget supplémentaire d'Umami (3 Ko, 1 domaine) est contrôlé séparément par le test unitaire de la configuration d'analytique (§9.8).

{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/mentions-legales",
        "http://localhost:3000/politique-de-confidentialite"
      ],
      "numberOfRuns": 3,
      "settings": {
        "formFactor": "mobile",
        "throttlingMethod": "simulate",
        "chromeFlags": "--no-sandbox --headless=new",
        "skipAudits": ["is-crawlable"]
      }
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9, "aggregationMethod": "median-run" }],
        "categories:accessibility": ["error", { "minScore": 1 }],
        "categories:best-practices": ["error", { "minScore": 0.95 }],
        "categories:seo": ["error", { "minScore": 1 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500, "aggregationMethod": "median-run" }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05, "aggregationMethod": "median-run" }],
        "total-blocking-time": ["error", { "maxNumericValue": 200, "aggregationMethod": "median-run" }],
        "first-contentful-paint": ["warn", { "maxNumericValue": 1800 }],
        "speed-index": ["warn", { "maxNumericValue": 3400 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 174080 }],
        "resource-summary:stylesheet:size": ["error", { "maxNumericValue": 35840 }],
        "resource-summary:font:size": ["error", { "maxNumericValue": 204800 }],
        "resource-summary:image:size": ["error", { "maxNumericValue": 358400 }],
        "resource-summary:document:size": ["error", { "maxNumericValue": 61440 }],
        "resource-summary:total:size": ["error", { "maxNumericValue": 921600 }],
        "resource-summary:third-party:count": ["error", { "maxNumericValue": 0 }],
        "dom-size": ["error", { "maxNumericValue": 1200 }],
        "unsized-images": "error",
        "uses-responsive-images": "error",
        "modern-image-formats": "error",
        "font-display": "error",
        "uses-long-cache-ttl": ["warn", { "minScore": 0.9 }]
      }
    },
    "upload": { "target": "filesystem", "outputDir": ".lighthouseci" }
  }
}

Les valeurs en octets correspondent aux budgets de §11.3.1 (170 Ko = 174 080 ; 35 Ko = 35 840 ; 200 Ko = 204 800 ; 350 Ko = 358 400 ; 60 Ko = 61 440 ; 900 Ko = 921 600).

11.3.12 Critères d'acceptation performance #

ID Critère Vérification
AC-11.3-01 Lighthouse mobile (médiane de 3, configuration unique lighthouserc.json) : Performance ≥ 90, Accessibilité 100, Bonnes pratiques ≥ 95, SEO 100 (audit is-crawlable exclu) sur les trois routes ; l'indexabilité de la production est contrôlée par le runbook de mise en production (§14). Lighthouse CI
AC-11.3-02 LCP ≤ 2,5 s, CLS ≤ 0,05, TBT ≤ 200 ms en laboratoire. Lighthouse CI
AC-11.3-03 JS compressé chargé avant load sur / (mobile) ≤ 170 Ko ; CSS ≤ 35 Ko ; HTML ≤ 60 Ko. Test Playwright + LHCI
AC-11.3-04 Images chargées au premier écran mobile (AVIF) ≤ 350 Ko ; seul le calque lointain du hero (hero-far) a fetchpriority="high" et un préchargement, limité à media="(min-width:1024px)" : aucun préchargement d'image n'est actif sous 1024 px, ni pour hero-mobile, ni pour le portrait. Test Playwright
AC-11.3-05 Toutes les images ont des dimensions réservées ; unsized-images = réussi ; aucune image du premier écran n'a loading="lazy". Lighthouse + E2E
AC-11.3-06 Les formats servis sont AVIF (navigateur compatible) ou WebP ; aucun JPEG/PNG de contenu n'est servi tel quel (hors og-default.jpg). E2E (en-têtes Content-Type)
AC-11.3-07 Au plus 4 préchargements de polices woff2, aucun pour l'italique de Fraunces (preload: false) ; aucune requête vers un domaine de polices tiers ; cumul préchargé ≤ 200 Ko. E2E (journal réseau + DOM)
AC-11.3-08 Aucun domaine tiers contacté avec ANALYTICS_PROVIDER=none ; au plus un (Umami) lorsqu'il est activé. Lighthouse (third-party:count) + E2E
AC-11.3-09 Les fichiers portant 'use client' sont exactement les fichiers marqués (C) de §12.2, chacun rattaché à un îlot de §9.2.3 (test de recherche des directives 'use client') ; le JavaScript de Motion chargé au premier écran est ≤ 22 Ko gzip. Test unitaire + check:bundle
AC-11.3-10 En-têtes de cache conformes à §11.3.8 (immuable un an pour les statiques, no-store pour /api/*, ETag sur le HTML avec la valeur par défaut de Next.js) ; toutes les règles proviennent du constructeur unique de src/lib/security/headers.ts. Test Playwright + test unitaire du constructeur
AC-11.3-11 CLS cumulé ≤ 0,05 pendant un défilement complet à 390, 768 et 1440 px, menu ouvert/fermé, onglets et situations changés. Test Playwright
AC-11.3-12 Chaque interaction listée en §11.3.9 point 7 dure ≤ 200 ms avec processeur ralenti ×4. Test Playwright
AC-11.3-13 Aucune animation CSS/JS ne porte sur width, height, top, left, margin ou box-shadow ; aucun content-visibility: auto ; aucun will-change statique. Test de recherche CSS
AC-11.3-14 scripts/warm-cache.sh s'exécute sans erreur sur le conteneur déployé et réduit le temps de la première requête d'image du hero à celui d'une réponse en cache (< 300 ms). Exécution manuelle consignée dans docs/perf-report.md
AC-11.3-15 Aucune bibliothèque web-vitals ni requête de collecte de performance n'est présente ; aucun traceur. Revue des dépendances + E2E (journal réseau)

12. Arborescence du projet et conventions de code #

Cette section est normative : les chemins, noms de fichiers, scripts et règles ci-dessous ne sont ni renommés ni réorganisés par l'agent exécutant. Les versions de chaque dépendance sont fixées en §9.1 (cette section n'en répète aucune). Le contenu fonctionnel des fichiers est défini dans les sections propriétaires indiquées en référence.

12.1 Principes directeurs #

# Principe Conséquence concrète
P1 Serveur par défaut Tout composant est un Server Component ; 'use client' n'apparaît que dans les « îlots » marqués (C) en §12.2 (règles en §12.5).
P2 Contenu séparé du code Aucun texte visiteur, prix, numéro, URL ni chiffre dans un composant : tout vient de src/content/fr/ (§9.3) ou du copy deck (§7).
P3 Frontière serveur/client explicite Les modules qui touchent aux secrets, au réseau sortant ou à l'état serveur portent import 'server-only' et sont interdits d'import côté composants ; le contenu et getBookingAction() sont interdits d'import dans les îlots client et les hooks (règles ESLint, §12.8.1).
P4 Une responsabilité, un dossier Un module = un fichier court (cible < 200 lignes) ; pas de fichier « fourre-tout » utils.ts.
P5 Rien d'implicite Aucun process.env hors src/lib/env.ts, src/instrumentation.ts, next.config.ts, scripts/, tests/ et fichiers *.config.* d'outils (s'y ajoute la lecture ciblée de APP_ENV par src/lib/content/guards.ts, §9.3.4). Aucun littéral hexadécimal hors src/styles/globals.css et src/lib/design/tokens.ts.
P6 Reproductible Tout se lance par pnpm <script> ou make <cible> (§12.9, §14.7) ; aucune étape manuelle cachée.
P7 Pas de sur-ingénierie Pas de monorepo, pas de couche « domain/repository », pas de state manager global, pas de middleware/proxy Next.js en MVP.

12.2 Arborescence annotée du dépôt #

Convention de lecture : (S) = Server Component ou module serveur ; (C) = îlot client ('use client') ; (G) = fichier généré (jamais édité à la main) ; (M) = fichier à fournir ou valider par Malka (le test d'arborescence de §12.15 ne l'exige pas à la livraison de développement). Les noms de fichiers sont en kebab-case ; les composants et fonctions exportés sont en PascalCase (§12.4). Chaque fichier de code, de configuration ou d'exploitation cité dans une autre section figure dans cette arborescence ; les fichiers de test sont décrits dans le détail en §15.1 (organisation) et §15.9 (fixtures) et suivent la règle de nommage de §12.4.

malka-debleds-site/
├── .github/
│   ├── workflows/
│   │   └── ci.yml                      # Pipeline CI complet (§14.15)
│   └── pull_request_template.md        # Checklist de revue (§12.14) reprise en cases à cocher
├── docs/
│   ├── assets-licences.json            # (M) Registre licences/droits des visuels, versionné, contrôlé par machine (§12.11.4)
│   ├── a11y-report.md                  # Rapport d'accessibilité conservé avec le dépôt (§15.7)
│   ├── perf-report.md                  # Rapport de performance (§15.8)
│   ├── progress.md                     # Journal d'avancement par jalon (§16)
│   └── go-live-report.md               # Preuves de mise en production (§15.13)
├── public/                             # Fichiers statiques servis tels quels ; aucun fichier généré
│   ├── images/
│   │   ├── hero/                       # (M) hero-far.webp, hero-mid.webp, hero-near.webp, hero-mobile.webp,
│   │   │                               #     hero-portrait.webp — un seul fichier maître par emplacement (§12.10)
│   │   ├── about/                      # (M) about-portrait.webp
│   │   ├── logo/                       # (M) malka-debleds.svg — facultatif ; sans fichier, logo typographique (§6.1)
│   │   ├── method/
│   │   │   └── ridge.svg               # Filet de crête décoratif de la section Méthode (§6.4)
│   │   └── dev-fallback/               #   SVG de développement uniquement (interdits en production, §12.11)
│   │       ├── hero-far.svg
│   │       ├── hero-mid.svg
│   │       ├── hero-near.svg
│   │       ├── hero-mobile.svg
│   │       ├── hero-portrait.svg       #   forme abstraite SANS visage
│   │       └── about-portrait.svg      #   forme abstraite SANS visage
│   ├── og/
│   │   └── og-default.jpg              # (M) 1200×630, image Open Graph par défaut
│   └── textures/
│       └── grain.svg                   # Texture de grain décorative (§5.4)
├── scripts/                            # Outils exécutés par `tsx` ou `bash`, jamais importés par l'application
│   ├── check-assets.ts                 # Garde-fous d'actifs et de production : visuels, dev-fallback, licences (§12.11)
│   ├── check-bundle-size.ts            # Budget de JavaScript de première charge (§15.8)
│   ├── check-no-fixtures.ts            # Absence de marqueurs de fixtures dans le build (§15.10)
│   └── warm-cache.sh                   # Préchauffage du cache d'images de Next.js après déploiement (§11.3)
├── src/
│   ├── instrumentation.ts              # (S) Hook Next.js `register()` : validation d'environnement au démarrage (§9.7, §13)
│   ├── app/                            # Routes (App Router) — fichiers spéciaux en export default uniquement ici
│   │   ├── layout.tsx                  # (S) <html lang="fr">, polices (fonts.ts), Providers, skip link, header ; conteneurs `#app-root` (page) et `#overlay-root` (portails, menu mobile) (§6.1.5)
│   │   ├── fonts.ts                    # (S) instances next/font : Fraunces (roman), Fraunces italique, Manrope (§5.5)
│   │   ├── page.tsx                    # (S) Landing : assemble les sections dans l'ordre de §4.2 ; SSG ; rend <JsonLd>
│   │   ├── icon.svg                    # Icône du site (convention de fichiers Next.js, pas de dossier public/favicon)
│   │   ├── apple-icon.png              # Icône tactile 180×180 (convention de fichiers Next.js)
│   │   ├── mentions-legales/
│   │   │   └── page.tsx                # (S) Rendu de `legal.ts` (§6.11)
│   │   ├── politique-de-confidentialite/
│   │   │   └── page.tsx                # (S) Politique RGPD ; bloc analytics conditionnel (§10)
│   │   ├── api/
│   │   │   ├── contact/
│   │   │   │   └── route.ts            # (S) POST — délègue à `lib/contact/handler.ts` (§9.4)
│   │   │   └── health/
│   │   │       └── route.ts            # (S) GET/HEAD — santé du conteneur (§9.4)
│   │   ├── not-found.tsx               # (S) 404 (§6.11)
│   │   ├── error.tsx                   # (C) frontière d'erreur de segment (§6.11)
│   │   ├── global-error.tsx            # (C) erreur racine, rend son propre <html lang="fr"> (§6.11)
│   │   ├── sitemap.ts                  # (S) /sitemap.xml — toujours les 3 URL (§11)
│   │   └── robots.ts                   # (S) /robots.txt (§11)
│   ├── components/
│   │   ├── layout/
│   │   │   ├── providers.tsx           # (C) LazyMotion (domAnimation) + MotionConfig reducedMotion="user" (§8.2.3)
│   │   │   ├── skip-link.tsx           # (S) « Aller au contenu » (cible #contenu)
│   │   │   ├── site-header.tsx         # (S) Coquille du header sticky (§6.1)
│   │   │   ├── header-client.tsx       # (C) état de défilement du header + coordination menu mobile (§6.1)
│   │   │   ├── desktop-nav.tsx         # (C) liens d'ancre + section active (§6.1)
│   │   │   ├── mobile-menu.tsx         # (C) menu plein écran accessible (§6.1)
│   │   │   └── legal-page-shell.tsx    # (S) gabarit des pages légales (§6.11)
│   │   ├── legal/
│   │   │   └── analytics-opt-out.tsx   # (C) bouton d'opposition à la mesure d'audience (§10.6)
│   │   ├── sections/                   # Une section de la landing = un dossier
│   │   │   ├── hero/                   #   hero-section.tsx (S), hero-far-layer.tsx (S), hero-band.tsx (S), hero-landscape.tsx (C),
│   │   │   │                           #   hero-portrait.tsx (C), hero-clarte-word.tsx (C), hero-method-rail.tsx (S),
│   │   │   │                           #   hero-trust-figures.tsx (C)
│   │   │   ├── philosophy/             #   philosophy-section.tsx (S), pull-quote.tsx (C)
│   │   │   ├── method/                 #   method-section.tsx (S), method-compass.tsx (C), method-step-panel.tsx (C)
│   │   │   ├── offers/                 #   offers-section.tsx (S), offer-item.tsx (S), offer-form-link.tsx (C),
│   │   │   │                           #   vat-mention.tsx (S)
│   │   │   ├── orientation/            #   orientation-section.tsx (S), orientation-compass.tsx (C)
│   │   │   ├── about/                  #   about-section.tsx (S)
│   │   │   ├── testimonials/           #   testimonials-section.tsx (S), testimonials-slider.tsx (C)
│   │   │   ├── contact/                #   contact-section.tsx (S), whatsapp-card.tsx (S)
│   │   │   └── footer/                 #   footer-section.tsx (S), footer-river.tsx (C), floating-cta-card.tsx (S),
│   │   │   │                           #   current-year.tsx (C)
│   │   ├── ui/                         # Primitives sans logique métier
│   │   │   ├── button.tsx              # (S) variantes primary/secondary/ghost, tailles, états
│   │   │   ├── nav-link.tsx            # (S) lien de navigation avec état actif (§6.1)
│   │   │   ├── dev-fallback-badge.tsx  # (S) pastille de développement sur les visuels de repli (§6.2)
│   │   │   ├── cta-link.tsx            # (C) lien d'action + `track()` (§9.8) ; ne construit jamais d'URL
│   │   │   ├── booking-link.tsx        # (S) rend un lien depuis `getBookingAction()` (§9)
│   │   │   ├── container.tsx           # (S) largeur de contenu 1280 px + gouttières (§5)
│   │   │   ├── section-shell.tsx       # (S) <section id aria-labelledby> + scroll-margin-top (§4.2)
│   │   │   ├── section-heading.tsx     # (S) eyebrow + titre + intro
│   │   │   ├── section-error-boundary.tsx # (C) frontière d'erreur des îlots interactifs (§9.7)
│   │   │   └── visually-hidden.tsx     # (S)
│   │   ├── motion/                     # Primitives d'animation — contrat §8 (toutes (C) sauf seq-lines)
│   │   │   ├── reveal.tsx              #   apparition au scroll
│   │   │   ├── reveal-lines.tsx        #   titre ligne par ligne (masque)
│   │   │   ├── seq-lines.tsx           #   (S) lignes de titre de la séquence CSS du hero
│   │   │   ├── stagger-group.tsx       #   <StaggerGroup>, <StaggerItem>
│   │   │   ├── parallax-layer.tsx      #   <ParallaxScene>, <ParallaxLayer> (useScroll + useTransform)
│   │   │   ├── ambient-drift.tsx       #   halos/brume : un cycle par entrée dans le viewport
│   │   │   ├── count-up.tsx            #   compteur des chiffres de confiance
│   │   │   ├── stable-swap.tsx         #   échange de contenu à hauteur stable (résultat d'orientation)
│   │   │   ├── panel-stack.tsx         #   (C) pile de panneaux à échange fondu (boussole de méthode, carrousel) (§8.2.2)
│   │   │   └── compass-needle.tsx      #   aiguille à ressort (§6.4)
│   │   ├── booking/                    # (extension §9.9.2, absent au MVP) calcom-embed.tsx (C)
│   │   └── forms/
│   │       ├── contact-form.tsx        # (C) formulaire complet (§6.9)
│   │       ├── form-field.tsx          # (C) label + champ + erreur reliés par aria-describedby
│   │       ├── honeypot-field.tsx      # (C) champ `website` masqué
│   │       ├── consent-checkbox.tsx    # (C)
│   │       ├── form-feedback.tsx       # (C) récapitulatif d'erreurs, succès/erreur (§6.9)
│   │       └── use-contact-form.ts     # (C) machine d'états idle|submitting|success|error
│   ├── hooks/                          # Hooks génériques (tous (C) par usage) : use-isomorphic-layout-effect.ts,
│   │                                   #   use-hydrated.ts, use-media-query.ts, use-reduced-motion-safe.ts,
│   │                                   #   use-is-desktop-pointer-fine.ts, use-save-data.ts, use-motion-profile.ts,
│   │                                   #   use-in-view-once.ts, use-reveal-phase.ts, use-ambient-state.ts,
│   │                                   #   use-section-observer.ts, use-active-section.ts, use-swap-direction.ts,
│   │                                   #   use-header-scroll-state.ts, use-scroll-lock.ts, use-focus-trap.ts
│   ├── content/
│   │   └── fr/                         # (M) Données typées validées par Zod (§9.3) ; seul dossier édité par Malka
│   │       ├── site.ts  navigation.ts  hero.ts  philosophy.ts  method.ts  offers.ts
│   │       ├── orientation.ts  about.ts  testimonials.ts  contact.ts  footer.ts
│   │       └── legal.ts  seo.ts
│   ├── lib/
│   │   ├── cn.ts                       # cn() = twMerge(clsx(...)) — unique définition
│   │   ├── env.ts                      # (S) lecture + validation Zod des variables (§13)
│   │   ├── design/
│   │   │   └── tokens.ts               #   constantes de design hors CSS (THEME_COLOR, couleurs de la page d'erreur racine) ; seul fichier .ts avec des littéraux hexadécimaux (§5.1.3)
│   │   ├── typography/
│   │   │   └── fr.ts                   #   frTypo() : espaces insécables du français, appliqué au chargement du contenu (§5.5.6)
│   │   ├── legal/
│   │   │   └── page-copy.ts            #   legalPageCopy, privacyPageCopy, getPrivacySections : textes des pages légales (.ts sans JSX, couverts par frTypo et la clé `copyDeck`) (§7.13)
│   │   ├── system-pages/
│   │   │   └── copy.ts                 #   notFoundCopy, errorPageCopy : textes des pages 404 et d'erreur (client-safe, utilisés par error.tsx) (§7.14)
│   │   ├── a11y/
│   │   │   └── labels.ts               #   libellés d'accessibilité partagés (client-safe, §11.2)
│   │   ├── motion/                     #   modules PURS du mouvement, sans hook React, importables côté serveur (§8.2.2) :
│   │   │                               #   tokens.ts, profile.ts, variants.ts, hero-sequence.ts, geometry.ts, stagger.ts,
│   │   │                               #   tween.ts, inline-emphasis.ts, index.ts
│   │   ├── content/
│   │   │   ├── index.ts                #   getContent('fr') : parse Zod de chaque fichier (échec = build KO)
│   │   │   ├── schemas.ts              #   schémas Zod (§9.3)
│   │   │   ├── types.ts                #   types inférés (seul import autorisé depuis `src/content/`)
│   │   │   ├── helpers.ts              #   fabriques de schémas communes : text(), isoDate… (§9.3.2)
│   │   │   ├── placeholders.ts         #   motifs de détection des textes provisoires (§9.3.6)
│   │   │   ├── forbidden-claims.ts     #   liste des formulations interdites (§9.3.6)
│   │   │   ├── testimonials.ts         #   règle « témoignage publiable » (§9.3.4)
│   │   │   └── guards.ts               #   gardes de production : champs légaux, dev-fallback (§9.3.7)
│   │   ├── contact/
│   │   │   ├── schema.ts               #   contactRequestSchema — PARTAGÉ client/serveur (pas de server-only)
│   │   │   ├── api-types.ts            #   types de requête/réponse de l'API (client-safe)
│   │   │   ├── prefill.ts              #   requestContactPrefill() : mémoire du module + événement `contact:prefill` (§6.5.4)
│   │   │   ├── handler.ts              # (S) logique de POST /api/contact (§9.4)
│   │   │   └── submit.ts               #   fetch côté client + mapping d'erreurs (client-safe)
│   │   ├── email/
│   │   │   ├── provider.ts             # (S) interface EmailProvider (§9.5)
│   │   │   ├── brevo.ts                # (S) adaptateur Brevo (fetch natif)
│   │   │   ├── templates.ts            # (S) texte + HTML
│   │   │   ├── escape.ts               # (S) échappement HTML
│   │   │   └── index.ts                # (S) getEmailProvider() selon EMAIL_DRY_RUN
│   │   ├── rate-limit/
│   │   │   ├── types.ts                # (S) interface RateLimiter
│   │   │   ├── config.ts               # (S) règles et plafonds (§9.6)
│   │   │   ├── memory-sliding-window.ts# (S) implémentation en mémoire (§9.6)
│   │   │   └── index.ts                # (S)
│   │   ├── booking/                    #   point d'extension Cal.com (§9.9) : index.ts (S, getBookingAction), whatsapp.ts, types.ts (client-safe)
│   │   ├── analytics/                  #   index.ts (track), events.ts (types), umami-script.tsx (S) (§9.8)
│   │   ├── seo/                        # (S) site-url.ts, json-ld.tsx (<JsonLd> : seul emploi de dangerouslySetInnerHTML) (§11)
│   │   ├── security/
│   │   │   └── headers.ts              #   constructeur pur des en-têtes de sécurité, appelé par next.config.ts (§10.3)
│   │   ├── log/
│   │   │   └── logger.ts               # (S) JSON lines stdout (§9.7), pas de PII
│   │   ├── format/                     #   price.ts (Intl fr-FR), phone.ts (affichage/E.164) (client-safe)
│   │   └── server/                     # (S) helpers de Route Handlers : request-id.ts, client-ip.ts,
│   │                                   #     origin-guard.ts, api-response.ts (enveloppe d'erreur §9.4)
│   └── styles/
│       ├── globals.css                 # @import "tailwindcss"; @theme (tokens §5) ; reset minimal ; focus ; reduced-motion
│       └── motion.css                  # tokens CSS de mouvement, séquence du hero, ambiances, micro-interactions (§8.3.5)
├── tests/
│   ├── unit/                           # Vitest (node) — miroir de src/ : lib/, motion/, security/… ; tooling/ = tests de structure
│   ├── components/                     # Vitest (jsdom) — Testing Library : boussole, orientation, carrousel, formulaire, menu
│   ├── integration/                    # Vitest (node) — route handlers, loader de contenu, adaptateur Brevo (fetch mocké)
│   ├── e2e/                            # Playwright — helpers/, journeys/, a11y/, visual/ ; matrice §15
│   └── fixtures/                       # contact-payloads.ts, brevo-responses.ts, content/ (variantes de témoignages et d'invalides),
│                                       # legal/ (légal complet et incomplet), setup-dom.ts, setup-node.ts,
│                                       # server-only-stub.ts
├── .dockerignore                       # (§14.3)
├── .editorconfig                       # utf-8, lf, indent 2, final newline
├── .env.example                        # Modèle commité, sans secret (contenu : §13)
├── .gitattributes                      # `* text=auto eol=lf` ; *.webp *.jpg *.png binary
├── .gitignore                          # node_modules, .next, .env, .env.*, !.env.example, coverage/, playwright-report/,
│                                       # test-results/, .lighthouseci/, *.tsbuildinfo
├── .nvmrc                              # ligne majeure de Node.js (§9.1)
├── .prettierignore                     # .next, public, pnpm-lock.yaml, coverage
├── .prettierrc.json                    # (§12.8.3)
├── Caddyfile                           # (§14.6)
├── DECISIONS.md                        # Journal des décisions DEC-NNN et des écarts au cahier des charges (§18.7)
├── Dockerfile                          # (§14.2)
├── Makefile                            # (§14.7)
├── README.md                           # 16 rubriques de §18.9.1 (§12.12)
├── compose.dev.yaml                    # (§14.5)
├── compose.hostport.yaml               # Surcharge optionnelle : publie web sur 127.0.0.1 (proxy existant, §14.5)
├── compose.yaml                        # (§14.4)
├── content-validation.json             # (M) Registre de validation par Malka : biographie, textes proposés, textes alternatifs (§1.3.3, G-05)
├── eslint.config.mjs                   # (§12.8.1) — unique configuration ESLint
├── lighthouserc.json                   # (§14.15.3) — unique configuration Lighthouse CI
├── next-env.d.ts                       # (G) gitignored
├── next.config.ts                      # output 'standalone', poweredByHeader false, images (formats, tailles, qualités), headers() (§10)
├── package.json                        # scripts (§12.9), packageManager (Corepack), engines.node
├── playwright.config.ts                # (§12.8.4) — unique configuration Playwright
├── pnpm-lock.yaml                      # Commité ; installé avec --frozen-lockfile
├── postcss.config.mjs                  # plugin `@tailwindcss/postcss` uniquement
├── tsconfig.json                       # (§12.7)
└── vitest.config.ts                    # (§12.8.5) — unique configuration Vitest

Règles d'arborescence :

  1. src/app ne contient que des fichiers de routage (et fonts.ts, icon.svg, apple-icon.png). Aucun composant réutilisable n'y vit ; les composants spécifiques à une section vivent dans src/components/sections/<section>/.
  2. Les fichiers de src/content/fr/ sont les seuls que Malka modifie. Chacun commence par un commentaire d'en-tête en français (rôle du fichier, champs modifiables, ce qu'il ne faut pas toucher). Tout autre commentaire de code est en anglais.
  3. public/ ne contient que des fichiers livrés tels quels : aucun traitement ni génération. Les visuels sont déposés par Malka en un seul fichier maître par emplacement (§12.10) ; dev-fallback/ est écrit à la main. Les icônes du site (icon.svg, apple-icon.png) vivent dans src/app/ et non dans public/.
  4. Aucun dossier de premier niveau supplémentaire (utils/, helpers/, types/, services/, assets-src/) n'est créé.
  5. src/middleware.ts / src/proxy.ts n'existent pas en MVP : les en-têtes de sécurité passent par headers() de next.config.ts, qui appelle le constructeur pur src/lib/security/headers.ts (§10).
  6. Les tests de structure du dépôt vivent dans tests/unit/tooling/ (arborescence, noms de fichiers, README, package.json, tsconfig.json, règles ESLint, contrôles d'assets) ; ils ne s'exécutent pas dans l'étape test du Dockerfile (le contexte de build exclut une partie des fichiers qu'ils vérifient, §14.3) mais dans le job quality de la CI (§14.15).
  7. Un hook ou un module propre à une seule section vit dans le dossier de cette section (par exemple les hooks de la boussole de méthode) ; src/hooks/ ne contient que des hooks partagés.

12.3 Alias d'import et frontières de modules #

12.3.1 Alias #

Un seul alias : @/* → src/* (déclaré dans tsconfig.json, relayé à Vitest par vitest.config.ts ; tsx le résout via tsconfig.json). Règles :

  • Imports entre dossiers de src/ : toujours via @/…. Imports relatifs (./) autorisés uniquement au sein du même dossier. Les ../ sont interdits dans src/ (règle ESLint no-restricted-imports motif ../*).
  • Les tests importent le code via @/… et les fixtures par chemin relatif (../fixtures/…).

12.3.2 Matrice serveur / client #

Module Marqueur Importable depuis components/, hooks/ (îlots client) ?
lib/env.ts, lib/log/*, lib/email/*, lib/rate-limit/*, lib/server/*, lib/contact/handler.ts import 'server-only' en première ligne Non (erreur ESLint + erreur de build Next.js)
lib/seo/* (dont json-ld.tsx), lib/analytics/umami-script.tsx import 'server-only' Non (rendus par app/ : layout et pages)
lib/booking/index.ts (getBookingAction, charge le contenu) aucun (importable par scripts/ et les tests) Non : appelé par les composants serveur (*-section.tsx, ui/booking-link.tsx) ; les îlots reçoivent les href en props
lib/booking/whatsapp.ts, lib/booking/types.ts aucun Oui (client-safe)
lib/contact/schema.ts, lib/contact/api-types.ts, lib/contact/prefill.ts, lib/contact/submit.ts aucun Oui
lib/analytics/index.ts (track), lib/analytics/events.ts aucun Oui
lib/format/*, lib/cn.ts, lib/a11y/*, lib/typography/*, lib/design/tokens.ts aucun Oui
lib/security/headers.ts aucun (constructeur pur : reçoit ses paramètres en argument, aucun process.env) Non par usage : appelé par next.config.ts
lib/motion/* aucun (modules purs, sans hook React) Oui
lib/content/types.ts aucun (types seuls) Oui (types uniquement)
lib/content/index.ts, schemas.ts, helpers.ts, placeholders.ts, forbidden-claims.ts, testimonials.ts, guards.ts aucun (doivent rester importables par scripts/ via tsx) Non dans les îlots client et les hooks : ils reçoivent des props issues du serveur, jamais le contenu complet ; les composants serveur peuvent les importer
src/content/fr/* aucun Non (importés uniquement par lib/content/index.ts)

Les composants serveur de src/components/ (sections, ui/booking-link.tsx) lisent getContent('fr') et getBookingAction() librement ; l'interdiction porte sur les îlots client (fichiers (C) de §12.2) et sur src/hooks/**. Le module lib/content/* ne porte volontairement pas server-only car les scripts check-assets.ts et les tests le chargent hors du runtime Next.js ; la règle « pas d'import depuis un îlot client » est donc portée par ESLint (§12.8.1), qui distingue les modules serveur (interdits dans tout composant et tout hook) des modules de contenu (interdits seulement dans les îlots client et les hooks).

12.3.3 Règle server-only #

Tout nouveau module qui lit un secret, appelle un service externe, garde un état serveur ou dépend de node:* commence par import 'server-only';. En test, Vitest remplace server-only par un module vide (tests/fixtures/server-only-stub.ts) via resolve.alias.

12.4 Conventions de nommage #

Objet Convention Exemple
Fichiers et dossiers kebab-case, minuscules, sans accent offer-card.tsx, mentions-legales/
Composants React PascalCase, export nommé (un composant principal par fichier) export function OfferCard()
Fichiers spéciaux Next.js export default autorisé uniquement pour page, layout, error, global-error, not-found, icon/apple-icon et fichiers de config (next.config.ts, playwright.config.ts, vitest.config.ts, eslint.config.mjs)
Hooks useX, fichier use-x.ts ; hooks partagés dans src/hooks/, hooks propres à une section dans le dossier de la section useActiveSection / use-active-section.ts
Types / interfaces PascalCase, pas de préfixe I Offer, ContactRequest
Schémas Zod camelCase + suffixe Schema contactRequestSchema
Constantes de module camelCase pour les constantes locales ; SCREAMING_SNAKE_CASE autorisé pour les constantes exportées de lib/** (jetons, listes fermées, limites : DURATION, SECTION_IDS, THEME_COLOR), les variables d'environnement et les codes d'erreur maxBodyBytes (local), DURATION (exporté)
Variables d'environnement UPPER_SNAKE_CASE, jamais de préfixe NEXT_PUBLIC_ BREVO_API_KEY
Identifiants de contenu kebab-case français sans accent, stables, jamais renommés après mise en ligne seance-clarification, decision-bloquee, ecouter
Identifiants de décision DEC-NNN dans DECISIONS.md ; les décisions de §14.1 sont DK-1 à DK-10, les arbitrages de §17 ARB-xx, les dépendances externes DEP-xx, les gardes G-00 à G-11 DEC-007, DK-4
Tests <module>.test.ts(x) (Vitest) ; <parcours>.spec.ts (Playwright) price.test.ts, contact-flow.spec.ts
Attributs de test data-testid (contrat de §15.2.5) ; data-layout (text, media, control, decor) sur les blocs mesurés par les tests de recouvrement, data-hero-box en alias dans le hero (§4.5) data-layout="text" data-hero-box="headline"
Propriétés CSS personnalisées --color-*, --font-*, --ease-* (déclarées dans @theme, §5) ; --z-* pour les couches (échelle de §5.7.4, aucun entier brut ailleurs) --color-forest-900, --z-header
Branches type/slug-court (§12.13.2) feat/hero-clarte
Langue du code Identifiants, commentaires et messages de commit en anglais ; chaînes visiteur et commentaires d'en-tête de src/content/fr/ en français

12.5 Règles de composants #

  1. Server par défaut. Un fichier sans 'use client' est un Server Component. Les sections *-section.tsx sont des Server Components qui lisent getContent('fr') (et getBookingAction() pour les liens WhatsApp) et passent des props sérialisables et minimales aux îlots.
  2. 'use client' uniquement dans les îlots, c'est-à-dire : tout fichier marqué (C) dans l'arborescence §12.2 et, en plus, error.tsx, global-error.tsx. Ajouter un îlot hors de cette liste exige de justifier le besoin d'état ou d'effet dans la description de la PR ; l'objectif est d'avoir ≤ 170 KB gzip de JS de première charge (§11).
  3. Îlots feuilles. Le marqueur client se place le plus bas possible dans l'arbre : une section serveur reste serveur et enveloppe ses enfants dans <Reveal> (client) en lui passant children, plutôt que de passer toute la section en client.
  4. Props. Types explicites (interface XProps), pas de React.FC, pas de any, pas de props booléennes opaques à plus de trois (utiliser une union de variantes). children typé React.ReactNode.
  5. Pas d'effet de bord à l'import. Aucun window/document au niveau module ; accès navigateur uniquement dans useEffect/gestionnaires, avec garde typeof window.
  6. États obligatoires. Tout composant interactif implémente et teste : repos, survol, focus clavier visible, actif, désactivé, chargement (si asynchrone), erreur (si faillible) ; et prefers-reduced-motion pour tout mouvement (§8).
  7. Accessibilité d'abord. Éléments natifs avant ARIA ; un lien navigue (<a>), un bouton agit (<button type="button">) ; zone interactive ≥ 24×24 px (44×44 px pour les actions primaires tactiles) ; aucun outline: none sans remplacement visible ; aucun tabindex positif.
  8. Pas de CTA qui fabrique une URL. Les liens WhatsApp passent exclusivement par getBookingAction() (§9.9), appelée côté serveur, et le composant ui/booking-link.tsx ; les îlots reçoivent l'href en prop et ne construisent aucune URL.
  9. Pas d'import de contenu dans un îlot. Un îlot ne reçoit jamais l'objet getContent() entier et n'importe ni src/content/fr/* ni lib/content/* ni lib/booking/index.ts (règle ESLint, §12.8.1).
  10. Images. Toute image raster passe par next/image avec sizes explicites, alt (vide uniquement pour une image décorative) et quality 60 ou 75 ; width/height ou fill dans un conteneur de ratio fixe garantissent zéro layout shift. Seul hero-far reçoit priority. L'élément <img> brut est interdit (@next/next/no-img-element) ; les SVG sont rendus avec unoptimized (§12.10.3).
  11. Noms de référence. Un composant est toujours désigné par son nom d'export du tableau ci-dessous ; un composant absent du tableau est un composant interne d'un fichier de la section.
Nom d'export Fichier (§12.2) Rôle
Providers layout/providers.tsx LazyMotion + MotionConfig + profil de mouvement (§8.2.3)
SiteHeader, DesktopNav, MobileMenu layout/site-header.tsx, desktop-nav.tsx, mobile-menu.tsx En-tête sticky : coquille serveur, liens d'ancre, menu plein écran (§6.1)
HeroSection, HeroFarLayer, HeroLandscape, HeroPortrait, HeroClarteWord, HeroMethodRail, HeroTrustFigures sections/hero/* Hero (§6.2)
MethodSection, MethodCompass, MethodStepPanel sections/method/* Boussole de clarté (§6.4)
OffersSection, OfferItem, OfferFormLink, VatMention sections/offers/* Accompagnements (§6.5)
OrientationSection, OrientationCompass sections/orientation/* Module d'orientation (§6.6)
TestimonialsSection, TestimonialsSlider sections/testimonials/* Témoignages (§6.8)
ContactSection, WhatsappCard, ContactForm sections/contact/*, forms/contact-form.tsx Contact (§6.9)
FooterSection, FooterRiver, FloatingCtaCard sections/footer/* Footer et carte d'appel à l'action flottante (§6.10)
CtaLink ui/cta-link.tsx Unique lien d'action suivi par track() (§9.8)
BookingLink ui/booking-link.tsx Lien rendu depuis getBookingAction() (§9.9)
SectionErrorBoundary ui/section-error-boundary.tsx Frontière d'erreur des îlots (§9.7)
Reveal, RevealLines, SeqLines, StaggerGroup, ParallaxScene/ParallaxLayer, AmbientDrift, CountUp, StableSwap, CompassNeedle motion/* Primitives de mouvement (§8)
JsonLd lib/seo/json-ld.tsx Données structurées (§11)
AnalyticsOptOut legal/analytics-opt-out.tsx Opposition à la mesure d'audience (§10.6)

12.6 Règles de style #

  1. Tailwind en utilitaires + tokens. Les couleurs, rayons, ombres, espacements de marque et polices viennent des tokens @theme de src/styles/globals.css (§5). Utiliser bg-forest-900, text-ivory-50, rounded-card… jamais de valeur brute équivalente.
  2. Aucune valeur de couleur arbitraire. Interdits dans les classes : bg-[#123456], text-[rgb(…)], border-[hsl(…)], from-[oklch(…)]. Interdits aussi : littéraux hexadécimaux dans les .ts/.tsx hors src/lib/design/tokens.ts (qui exporte THEME_COLOR et les couleurs de la page d'erreur racine). Appliqué par ESLint (§12.8.1).
  3. Valeurs arbitraires de longueur (h-[72px]) tolérées uniquement pour des dimensions de composition documentées en §5/§6 (ex. hauteur de header) et préférablement via une variable CSS (h-(--header-h)).
  4. cn() : src/lib/cn.ts exporte cn(...inputs: ClassValue[]) => twMerge(clsx(inputs)) (paquets clsx et tailwind-merge). Toute composition conditionnelle de classes passe par cn() ; pas de concaténation de chaînes ni de template literal pour des classes.
  5. Variantes de composants : objets Record<Variant, string> typés dans le fichier du composant, pas de bibliothèque de variantes supplémentaire.
  6. CSS hors Tailwind limité à globals.css (et à motion.css qu'il importe, §8.3.5) : @theme, reset minimal, :focus-visible, @media (prefers-reduced-motion: reduce), dégradés atmosphériques complexes nommés (.bg-atmosphere-*) lorsqu'ils ne s'expriment pas en utilitaires.
  7. Ordre des classes imposé par prettier-plugin-tailwindcss (§12.8.3).
  8. Mobile-first. Les classes de base visent 320 px ; md:, lg:… ajoutent. Aucun max-*: sauf justification en commentaire.
  9. Aucune animation en CSS libre (transition: all, @keyframes décoratifs hors de motion.css) : le mouvement est porté par Motion (§8) et par les feuilles de src/styles/motion.css (séquence du hero, ambiances, micro-interactions) ; seules les transitions de couleur/opacité des états de survol/focus utilisent transition-colors/transition-opacity avec les durées de §5. Toute transformation de survol ou d'appui passe par .motion-press, .motion-lift ou .motion-arrow (neutralisées en mouvement réduit) ; les classes hover:translate-*, hover:scale-* et équivalents sont interdites par ESLint.

12.7 TypeScript (tsconfig.json) #

{
  "compilerOptions": {
    "target": "ES2023",
    "lib": ["dom", "dom.iterable", "ES2023"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "forceConsistentCasingInFileNames": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "allowJs": false,
    "skipLibCheck": true,
    "noEmit": true,
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules", ".next", "coverage", "playwright-report", "test-results"]
}

Règles associées :

  • strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch sont obligatoires et ne sont désactivés nulle part (ni par fichier, ni par // @ts-ignore). // @ts-expect-error est toléré uniquement dans tests/ avec une raison écrite.
  • Les chaînes de type « union de littéraux » remplacent les enum. Les switch sur une union se terminent par un default qui affecte const _exhaustive: never = value.
  • import type / export type systématiques pour les types (verbatimModuleSyntax).
  • Les données de src/content/fr/ utilisent satisfies avec les types de lib/content/types.ts pour conserver les littéraux et vérifier la forme.
  • any est interdit (ESLint @typescript-eslint/no-explicit-any) ; l'entrée non fiable est typée unknown puis validée par Zod.

12.8 Qualité automatisée #

12.8.1 ESLint (eslint.config.mjs, configuration plate) #

eslint.config.mjs est l'unique configuration ESLint du dépôt : aucune autre section n'en définit de fragment. Base : configuration Next.js (core-web-vitals + TypeScript), typescript-eslint en mode « type-checked » (projectService), eslint-config-prettier en dernier. Le plugin eslint-plugin-jsx-a11y est enregistré par la configuration Next.js ; les règles ci-dessous le portent au niveau error. La CI exécute pnpm lint avec --max-warnings 0 (§12.9).

// eslint.config.mjs (structure normative ; compléter les imports selon §9.1)
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier/flat';

const a11yRules = Object.fromEntries(
  [
    'alt-text', 'anchor-has-content', 'anchor-is-valid', 'aria-props', 'aria-proptypes',
    'aria-role', 'aria-unsupported-elements', 'click-events-have-key-events',
    'control-has-associated-label', 'heading-has-content', 'html-has-lang', 'iframe-has-title',
    'img-redundant-alt', 'interactive-supports-focus', 'label-has-associated-control', 'lang',
    'media-has-caption', 'mouse-events-have-key-events', 'no-access-key', 'no-autofocus',
    'no-distracting-elements', 'no-interactive-element-to-noninteractive-role',
    'no-noninteractive-element-interactions', 'no-noninteractive-element-to-interactive-role',
    'no-noninteractive-tabindex', 'no-redundant-roles', 'no-static-element-interactions',
    'prefer-tag-over-role', 'role-has-required-aria-props', 'role-supports-aria-props', 'scope',
    'tabindex-no-positive',
  ].map((name) => [`jsx-a11y/${name}`, 'error']),
);

/* ---- Règles de syntaxe (no-restricted-syntax), assemblées par fichier ---- */
const colorClass = {
  selector: "Literal[value=/\\b(bg|text|border|fill|stroke|from|via|to|ring|shadow|outline|decoration|accent|caret|divide|placeholder)-\\[(#|rgb|hsl|hwb|lab|lch|oklab|oklch|color)/]",
  message: 'Couleur arbitraire interdite : utiliser un token (voir §12.6).',
};
const hexLiteral = {
  selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]',
  message: 'Littéral hexadécimal interdit hors globals.css et lib/design/tokens.ts : utiliser un token (voir §12.6).',
};
const processEnv = {
  selector: "MemberExpression[object.name='process'][property.name='env']",
  message: 'process.env interdit ici : passer par src/lib/env.ts (voir §12.1, P5).',
};
const scrollListener = {
  selector: "CallExpression[callee.property.name='addEventListener'][arguments.0.value=/^(scroll|wheel|touchmove|mousewheel)$/]",
  message: 'Écouteur de défilement manuel interdit : useScroll ou IntersectionObserver (voir §8.7).',
};
const hoverTransform = {
  selector: 'Literal[value=/\\b(hover|active|focus-visible|focus-within|group-hover):-?(translate|scale|rotate)/]',
  message: 'Transformation de survol/appui uniquement via .motion-press, .motion-lift ou .motion-arrow (voir §8.3.5).',
};
const encodeUri = {
  selector: "CallExpression[callee.name='encodeURI']",
  message: 'Utiliser encodeURIComponent, jamais encodeURI (voir §9.9).',
};
const testOnly = {
  selector: "MemberExpression[property.name='only'][object.name=/^(test|it|describe)$/]",
  message: 'test.only / describe.only / it.only interdits.',
};
const srcSyntax = [colorClass, hexLiteral, processEnv, scrollListener, hoverTransform];
const syntax = (rules) => ['error', ...rules];

/* ---- Frontières d'import (no-restricted-imports), assemblées par fichier ---- */
const relativeImports = { group: ['../*'], message: 'Utiliser l’alias @/ (voir §12.3.1).' };
const testImports = { group: ['**/tests/**', '**/fixtures/**'], message: 'Le code de production n’importe jamais de test ni de fixture (voir §15.10).' };
// Modules serveur : interdits dans tout composant, hook ou page d'erreur.
const serverOnlyModules = {
  group: ['@/lib/env', '@/lib/log', '@/lib/log/*', '@/lib/email', '@/lib/email/*', '@/lib/rate-limit',
          '@/lib/rate-limit/*', '@/lib/server', '@/lib/server/*', '@/lib/seo', '@/lib/seo/*',
          '@/lib/contact/handler', '@/lib/analytics/umami-script', 'server-only', 'node:*'],
  message: 'Module serveur : interdit dans un composant ou un hook (voir §12.3.2).',
};
// Contenu et réglages serveur du contenu : interdits dans les îlots client et les hooks
// (les composants serveur `*-section.tsx` et `ui/booking-link.tsx` les importent librement).
const contentModules = {
  group: ['@/content/*', '@/lib/content', '@/lib/content/index', '@/lib/content/schemas',
          '@/lib/content/guards', '@/lib/booking', '@/lib/booking/index'],
  message: 'Un îlot client reçoit des props, jamais le contenu ni getBookingAction (voir §12.3.2).',
};
const motionPaths = (allowDomAnimation) => [
  { name: 'framer-motion', message: 'Le paquet framer-motion n’est pas installé : importer depuis motion/react (voir §8.2.1).' },
  { name: 'motion/react',
    importNames: ['motion', 'domMax', 'animate', 'useAnimationFrame', ...(allowDomAnimation ? [] : ['domAnimation'])],
    message: 'Nom interdit : utiliser m.*, LazyMotion et runTween (voir §8.2.1).' },
];
const restrict = (patterns, { allowDomAnimation = false } = {}) =>
  ['error', { paths: motionPaths(allowDomAnimation), patterns }];

// Fichiers 'use client' (marqués (C) en §12.2) : liste fermée, vérifiée par un test (AC-12.5-01).
const clientIslandFiles = [
  'src/components/layout/{providers,header-client,desktop-nav,mobile-menu}.tsx',
  'src/components/legal/analytics-opt-out.tsx',
  'src/components/sections/hero/{hero-landscape,hero-portrait,hero-clarte-word,hero-trust-figures}.tsx',
  'src/components/sections/method/{method-compass,method-step-panel}.tsx',
  'src/components/sections/offers/offer-form-link.tsx',
  'src/components/sections/orientation/orientation-compass.tsx',
  'src/components/sections/testimonials/testimonials-slider.tsx',
  'src/components/sections/philosophy/pull-quote.tsx',
  'src/components/sections/footer/{footer-river,current-year}.tsx',
  'src/components/ui/{cta-link,section-error-boundary}.tsx',
  'src/components/motion/!(seq-lines).tsx',
  'src/components/forms/**',
  'src/components/sections/**/use-*.ts',      // hooks propres à une section (§12.2, règle 7)
  'src/app/error.tsx', 'src/app/global-error.tsx',
];

export default defineConfig([
  ...nextVitals,
  ...nextTs,
  ...tseslint.configs.strictTypeChecked.map((c) => ({ ...c, files: ['**/*.{ts,tsx}'] })),
  {
    languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
    rules: {
      ...a11yRules,
      '@typescript-eslint/consistent-type-imports': ['error', { fixStyle: 'separate-type-imports' }],
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/switch-exhaustiveness-check': 'error',
      '@next/next/no-img-element': 'error',
      'no-console': ['error', { allow: [] }],
      'react/jsx-no-target-blank': 'error',
      'react/no-danger': 'error',
    },
  },
  // 1. Tout `src/` : règles de syntaxe complètes et frontières de base.
  { files: ['src/**/*.{ts,tsx}'],
    rules: { 'no-restricted-syntax': syntax(srcSyntax),
             'no-restricted-imports': restrict([relativeImports, testImports]) } },
  // 2. Composants, hooks, pages d'erreur : aucun module serveur.
  { files: ['src/components/**', 'src/hooks/**', 'src/app/**/error.tsx', 'src/app/global-error.tsx'],
    rules: { 'no-restricted-imports': restrict([relativeImports, testImports, serverOnlyModules]) } },
  // 3. Îlots client et hooks : ni contenu ni getBookingAction (props uniquement).
  { files: [...clientIslandFiles, 'src/hooks/**'],
    rules: { 'no-restricted-imports': restrict([relativeImports, testImports, serverOnlyModules, contentModules]) } },
  // 4. Fournisseur racine : seul fichier autorisé à importer domAnimation.
  { files: ['src/components/layout/providers.tsx'],
    rules: { 'no-restricted-imports': restrict([relativeImports, testImports, serverOnlyModules, contentModules],
                                               { allowDomAnimation: true }) } },
  // 5. Fichiers de contenu : uniquement les types.
  { files: ['src/content/**'],
    rules: { 'no-restricted-imports': restrict([
      { group: ['*', '!@/lib/content/types'], message: 'Les fichiers de contenu n’importent que @/lib/content/types.' },
    ]) } },
  // 6. Dérogations ciblées.
  { files: ['src/lib/env.ts', 'src/instrumentation.ts', 'src/lib/content/guards.ts'],   // seuls lecteurs de process.env dans src/
    rules: { 'no-restricted-syntax': syntax([colorClass, hexLiteral, scrollListener, hoverTransform]) } },
  { files: ['src/lib/design/tokens.ts'],                                              // seul fichier .ts avec des littéraux hexadécimaux
    rules: { 'no-restricted-syntax': syntax([colorClass, processEnv, scrollListener, hoverTransform]) } },
  { files: ['src/lib/booking/**'],
    rules: { 'no-restricted-syntax': syntax([...srcSyntax, encodeUri]) } },
  // 7. Hors `src/` : scripts, tests et fichiers de configuration d'outils.
  { files: ['scripts/**', 'next.config.ts', '*.config.{ts,mjs}'],
    rules: { 'no-restricted-syntax': 'off', 'no-console': 'off' } },
  { files: ['tests/**'],
    rules: { 'no-restricted-syntax': syntax([testOnly]), 'no-console': 'off' } },
  globalIgnores(['.next/**', 'coverage/**', 'playwright-report/**', 'test-results/**',
    '.lighthouseci/**', 'next-env.d.ts', 'public/**']),
  prettier,
]);

Précisions :

  • Lecture de process.env : autorisée uniquement dans src/lib/env.ts, src/instrumentation.ts, next.config.ts, scripts/**, tests/** et les fichiers *.config.* des outils ; s'y ajoute la lecture ciblée de APP_ENV par src/lib/content/guards.ts (§9.3.4). Tout autre fichier passe par src/lib/env.ts (principe P5).
  • Littéraux hexadécimaux : autorisés uniquement dans src/styles/globals.css (hors périmètre ESLint) et src/lib/design/tokens.ts. viewport.themeColor et les couleurs inline de global-error.tsx importent leurs constantes depuis tokens.ts ; aucun autre fichier de src/ ne contient de littéral de couleur.
  • Contenu et composants : les composants serveur (*-section.tsx, ui/booking-link.tsx) lisent getContent('fr') et getBookingAction() et passent des props sérialisables aux îlots ; seuls les îlots (liste clientIslandFiles, identique aux fichiers marqués (C) de §12.2) et les hooks ne peuvent importer ni le contenu ni lib/booking/index.ts.
  • react/no-danger est à error ; la seule exception est le <script type="application/ld+json"> rendu par <JsonLd> dans src/lib/seo/json-ld.tsx, annoté // eslint-disable-next-line react/no-danger -- JSON-LD serialised by JSON.stringify et jamais alimenté par une saisie visiteur. Aucun autre fichier n'emploie dangerouslySetInnerHTML.
  • @next/next/no-img-element interdit l'élément <img> brut : toute image passe par next/image (§12.5, règle 10).
  • console.* est interdit dans src/ : tout log passe par lib/log/logger.ts.
  • Les règles no-restricted-syntax « hover/appui » et « écouteur de défilement » appliquent respectivement §8.3.5 (les transformations de survol ne passent que par les classes .motion-*, neutralisées en mouvement réduit) et §8.7 (aucun écouteur scroll, wheel, touchmove manuel).
  • Un test unitaire tests/unit/tooling/lint-rules.test.ts charge l'API ESLint et vérifie, via lintText, que : une classe bg-[#fff], un littéral '#13301F' dans un composant, un process.env dans un composant, un import de @/lib/email dans un fichier src/components/**, un import de @/lib/content dans src/components/sections/method/method-compass.tsx, un hover:-translate-y-1, un <img> sans alt et un test.only produisent chacun au moins une erreur, et que l'import de @/lib/content dans src/components/sections/method/method-section.tsx n'en produit aucune.

12.8.2 Accessibilité statique et dynamique #

  • Statique : règles jsx-a11y/* ci-dessus (échec de pnpm lint).
  • Dynamique : @axe-core/playwright dans les parcours E2E (§15.7) — aucune violation de niveau serious/critical sur /, /mentions-legales, /politique-de-confidentialite aux largeurs et dans les états définis en §15.7 ; les violations moderate/minor sont consignées dans docs/a11y-report.md.

12.8.3 Prettier (.prettierrc.json) #

{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "arrowParens": "always",
  "endOfLine": "lf",
  "plugins": ["prettier-plugin-tailwindcss"],
  "tailwindStylesheet": "./src/styles/globals.css",
  "tailwindFunctions": ["cn"]
}

pnpm format réécrit, pnpm format:check (CI) échoue sur la moindre différence.

12.8.4 Playwright (playwright.config.ts) #

playwright.config.ts est l'unique configuration Playwright ; la matrice de projets et les balises de tests sont celles de §15.6. Les tests ne démarrent aucun serveur (pas de webServer) : ils ciblent des URL déjà servies par les conteneurs Compose du profil e2e (§14.4).

import { defineConfig, devices } from '@playwright/test';

// Server under test: service `web-e2e` (production image, default content).
// Tests that need published testimonials and complete legal notices call
// test.use({ baseURL: FIXTURES_URL }) from tests/e2e/helpers/urls.ts (service `web-e2e-fixtures`).
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000';
const allBrowsers = process.env.PW_ALL_BROWSERS === '1';

export default defineConfig({
  testDir: './tests/e2e',
  outputDir: './test-results',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 2 : undefined,
  timeout: 30_000,
  expect: {
    timeout: 7_000,
    toHaveScreenshot: { maxDiffPixelRatio: 0.02, animations: 'disabled', caret: 'hide' },
  },
  reporter: process.env.CI
    ? [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }], ['github']]
    : [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
  use: {
    baseURL,
    locale: 'fr-FR',
    timezoneId: 'Europe/Paris',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: 'off',
    // Every test that submits the form sets its own X-Forwarded-For (tests/e2e/helpers/contact.ts):
    // the servers run with TRUST_PROXY=true and the rate limiter keys on the client address.
  },
  projects: [
    { name: 'chromium-desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] }, grepInvert: /@desktop-only|@visual/ },
    ...(allBrowsers
      ? [
          { name: 'firefox-desktop', use: { ...devices['Desktop Firefox'], viewport: { width: 1440, height: 900 } }, grep: /@smoke|@cross/ },
          { name: 'webkit-desktop', use: { ...devices['Desktop Safari'], viewport: { width: 1440, height: 900 } }, grep: /@smoke|@cross/ },
          { name: 'mobile-safari', use: { ...devices['iPhone 14'] }, grep: /@smoke|@cross|@mobile/ },
        ]
      : []),
  ],
});

Les cinq projets de §15.6 existent dès que PW_ALL_BROWSERS=1 (matrice complète de la CI, avec PLAYWRIGHT_BROWSERS="chromium firefox webkit" pour l'image e2e, §14.4) ; sans cette variable, les deux projets Chromium suffisent au poste de développement. Chaque test qui soumet le formulaire fixe un en-tête X-Forwarded-For unique (fixture partagée dans tests/e2e/helpers/contact.ts), le test de limitation de débit étant le seul à réutiliser volontairement la même valeur. tests/e2e/helpers/urls.ts exporte BASE_URL et FIXTURES_URL (variables PLAYWRIGHT_BASE_URL et PLAYWRIGHT_FIXTURES_URL, §13.3). L'SITE_URL cuite dans chaque image de test est exactement l'URL utilisée par le navigateur, car le contrôle d'origine de §9.4 la compare à l'en-tête Origin.

12.8.5 Vitest (vitest.config.ts) #

vitest.config.ts est l'unique configuration Vitest : trois « projects » (unit, components, integration), l'alias server-only, le plugin React et des seuils de couverture par périmètre (valeurs : §15.1.3).

import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';

const r = (p: string) => fileURLToPath(new URL(p, import.meta.url));

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': r('./src'),
      'server-only': r('./tests/fixtures/server-only-stub.ts'),
    },
  },
  test: {
    globals: false,
    restoreMocks: true,
    unstubEnvs: true,
    unstubGlobals: true,
    projects: [
      {
        extends: true,
        test: {
          name: 'unit',
          environment: 'node',
          include: ['tests/unit/**/*.test.ts'],
          setupFiles: ['tests/fixtures/setup-node.ts'],
        },
      },
      {
        extends: true,
        test: {
          name: 'components',
          environment: 'jsdom',
          include: ['tests/components/**/*.test.tsx'],
          setupFiles: ['tests/fixtures/setup-dom.ts'],
        },
      },
      {
        extends: true,
        test: {
          name: 'integration',
          environment: 'node',
          include: ['tests/integration/**/*.test.ts'],
          setupFiles: ['tests/fixtures/setup-node.ts'],
          pool: 'forks',
        },
      },
    ],
    coverage: {
      provider: 'v8',
      include: ['src/lib/**', 'src/app/api/**', 'src/components/**'],
      exclude: ['src/lib/**/index.ts', 'src/lib/**/types.ts'],
      reporter: ['text-summary', 'lcov'],
      thresholds: {
        lines: 85, functions: 85, branches: 75, statements: 85,
        'src/lib/**': { lines: 90, functions: 90, branches: 85, statements: 90 },
        'src/app/api/**': { lines: 95, functions: 100, branches: 90, statements: 95 },
        'src/components/{forms,motion}/**': { lines: 85, functions: 85, branches: 75, statements: 85 },
        'src/components/sections/{method,orientation,testimonials}/**': { lines: 85, functions: 85, branches: 75, statements: 85 },
        'src/components/layout/mobile-menu.tsx': { lines: 85, functions: 85, branches: 75, statements: 85 },
      },
    },
  },
});

tests/fixtures/setup-node.ts fixe un environnement de test minimal (EMAIL_DRY_RUN=true, LOG_SALT factice de 32 caractères ou plus, SITE_URL=http://localhost:3000, TRUST_PROXY=true) ; setup-dom.ts importe les matchers @testing-library/jest-dom et simule matchMedia, IntersectionObserver et ResizeObserver. Un test qui a besoin d'une autre valeur la fixe localement avec vi.stubEnv. Les tests de structure tests/unit/tooling/** appartiennent au projet unit ; l'étape test du Dockerfile les exclut (§14.2).

12.9 Scripts package.json #

{
  "scripts": {
    "dev": "next dev",
    "build": "pnpm check:assets && next build",
    "start": "node .next/standalone/server.js",
    "lint": "eslint . --max-warnings 0",
    "lint:fix": "eslint . --fix",
    "typecheck": "tsc --noEmit",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "test": "vitest run",
    "test:unit": "vitest run --project unit",
    "test:components": "vitest run --project components",
    "test:integration": "vitest run --project integration",
    "test:watch": "vitest --project unit",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test --grep-invert @visual",
    "test:a11y": "playwright test --project=chromium-desktop --grep @a11y",
    "test:visual": "playwright test --project=chromium-desktop --grep @visual",
    "test:visual:update": "playwright test --project=chromium-desktop --grep @visual --update-snapshots",
    "lighthouse": "lhci autorun --config=lighthouserc.json",
    "check:assets": "tsx scripts/check-assets.ts",
    "check:bundle": "tsx scripts/check-bundle-size.ts",
    "check:guards": "tsx scripts/check-assets.ts --guards-only",
    "check:no-fixtures": "tsx scripts/check-no-fixtures.ts",
    "verify": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test"
  }
}
Script Rôle Remarques
dev Serveur de développement En conteneur : pnpm dev -H 0.0.0.0 (§14.5).
build Contrôles d'actifs et de production, puis build Next.js L'échec de check:assets (ou des gardes de contenu en APP_ENV=production) interrompt le build.
start Lance le serveur standalone Réservé à l'intérieur de l'image ; en local, utiliser make up (le conteneur copie public/ et .next/static).
lint ESLint sans aucun avertissement toléré Équivalent de la CI.
test Les trois projets Vitest (unit, components, integration), tests de structure inclus L'étape test du Dockerfile (§14.2) exécute les mêmes projets, hors tests/unit/tooling.
test:coverage Les trois projets avec seuils de couverture (§15.1.3) Utilisé par la CI.
test:e2e Playwright, hors tests visuels Cible PLAYWRIGHT_BASE_URL (défaut http://localhost:3000) ; en pratique lancé par make test-e2e dans Compose.
test:a11y Scans axe (balise @a11y) Voir §15.7.
test:visual, test:visual:update Régression visuelle (Linux uniquement) ; mise à jour des références test:visual:update ne s'exécute que dans le conteneur Playwright (§15.9).
lighthouse Lighthouse CI (§14.15.3) Requiert Chrome local et un site servi sur http://localhost:3000.
check:assets Contrôles d'actifs (§12.11) Code de sortie ≠ 0 si erreur.
check:bundle Budget de JavaScript de première charge (§15.8) À exécuter sur le build construit.
check:guards Gardes de production G-00 à G-11 par appel direct (§9.3.7, §15.10) Même code que check:assets, sans lecture des fichiers d'image.
check:no-fixtures Absence de marqueurs de fixtures dans le build (§15.10) À exécuter sur chaque image construite.
verify Contrôle local complet avant PR Aucun test E2E (trop long).

Les 24 scripts ci-dessus sont les seuls ; il n'existe pas de script images (les visuels ne sont pas traités par le dépôt, §12.10). package.json déclare aussi : "private": true, "packageManager" (renseigné par corepack use pnpm@latest, ligne majeure : voir §9.1), "engines": { "node": ">=24 <25" } et "type" absent (le projet reste en CommonJS-compatible côté Next.js ; les fichiers de configuration ESM utilisent l'extension .mjs). Les dépendances sont installées à leur version stable courante au moment du build et figées par pnpm-lock.yaml (voir §9.1). Les scripts de cycle de vie pre*/post* ne sont pas utilisés : l'enchaînement est explicite (build).

12.10 Livraison des visuels (aucun pipeline de conversion) #

12.10.1 Principes #

Malka (ou son prestataire) livre un seul fichier maître par emplacement, directement dans public/images/<dossier>/, au nom et au format du tableau 12.10.2. Le dépôt ne convertit, ne redimensionne et ne génère rien : il n'existe ni script de traitement d'images, ni manifeste, ni variantes suffixées par une largeur, ni dossier d'originaux. Les formats AVIF et WebP aux largeurs demandées sont produits à la demande par next/image (sharp, présent dans la sortie standalone), selon la configuration de 12.10.3.

  1. Un fichier = un emplacement. Les chemins sont ceux référencés par src/content/fr/*.ts ; ils ne changent pas. Pour remplacer un visuel, déposer le nouveau fichier sous un nouveau nom (suffixe -v2, -v3…) et mettre à jour le chemin dans le fichier de contenu : le cache d'images est ainsi invalidé sans purge.
  2. Aucune métadonnée. Les fichiers sont livrés sans EXIF, sans GPS et sans profil ICC autre que sRGB (exigence de confidentialité pour les photos de Malka) ; check:assets refuse un fichier qui en contient (ASSET_METADATA).
  3. Aucun traitement du visage. Le portrait est la photo réelle de Malka, détourée sur fond transparent par le prestataire ; le dépôt n'en retouche, n'en génère ni n'en reconstruit aucun trait.
  4. Footer entièrement vectoriel. Les montagnes et la rivière du footer sont des SVG écrits dans le code (§6.10) : aucun emplacement raster ni aucune ligne de licence ne leur correspond.
  5. Précharge. Seul hero-far est préchargé (priority) ; tous les autres visuels sont chargés en différé (§11.3).

12.10.2 Emplacements normatifs #

Fichier (public/) Rôle Format Dimensions minimales Ratio (tolérance ±1 %) Canal alpha
images/hero/hero-far.webp Calque lointain du hero (ciel, montagnes) WebP sans perte 3840×1920 2:1 non requis
images/hero/hero-mid.webp Calque de brume WebP sans perte 3840×1920 2:1 requis
images/hero/hero-near.webp Calque de crête proche WebP sans perte 3840×1920 2:1 requis
images/hero/hero-mobile.webp Paysage aplati, utilisé uniquement dans la bande du hero sous 1024 px (object-fit: cover) WebP 1600×1000 16:10 (paysage) non
images/hero/hero-portrait.webp Portrait détouré de Malka WebP sans perte 1920×2400 4:5 requis
images/about/about-portrait.webp Portrait de la section À propos WebP 1600×2000 4:5 non
og/og-default.jpg Image Open Graph par défaut JPEG 1200×630 (exactes) 1200:630 non

Le logo images/logo/malka-debleds.svg (facultatif) et images/method/ridge.svg sont des SVG vectoriels livrés avec le dépôt ou par Malka ; ils sont rendus tels quels, sans optimisation. Les spécifications visuelles de chaque calque (composition, recouvrement, parallaxe) relèvent de §5.9 et §6.2.

12.10.3 Rendu : next/image et configuration #

Toute image raster est rendue par next/image avec sizes explicites et alt (chaîne vide uniquement pour une image décorative). La configuration images de next.config.ts est :

images: {
  formats: ['image/avif', 'image/webp'],
  deviceSizes: [640, 828, 1080, 1280, 1920, 2560],
  qualities: [60, 75],
  localPatterns: [{ pathname: '/images/**' }],   // nothing outside public/images is optimised
},

Chaque appel à next/image utilise quality 60 ou 75 (seules valeurs autorisées). Les fichiers .svg (secours de développement, logo, filets) sont rendus avec unoptimized. L'optimisation à l'exécution est gourmande en mémoire (encodage AVIF à partir de maîtres de 3840 px) : le conteneur web dispose de mem_limit: 768m (§14.4), le cache vit dans le tmpfs /app/.next/cache, et le script scripts/warm-cache.sh le réchauffe après chaque déploiement (§11.3).

12.10.4 Secours de développement #

public/images/dev-fallback/ contient six SVG abstraits sans visage : hero-far.svg, hero-mid.svg, hero-near.svg, hero-mobile.svg, hero-portrait.svg et about-portrait.svg. Les emplacements d'image du contenu acceptent un SVG en développement et en préproduction (§9.3.2) ; les contrôles de format raster, de dimensions et de ratio sont des gardes de production. Toute référence à dev-fallback fait échouer le build de production (garde G-02, ASSET_DEV_FALLBACK).

12.11 Garde-fous d'actifs (scripts/check-assets.ts) #

12.11.1 Entrée et déroulé #

Exécuté par pnpm check:assets et en tête de pnpm build. Environnement lu : APP_ENV (défaut development). Option --guards-only (script check:guards) : n'exécute que les gardes de production de §9.3.7, par appel direct des fonctions de lib/content/guards.ts, sans ouvrir les fichiers d'image. Étapes :

  1. Charger getContent('fr') (échec Zod = erreur immédiate, le message de Zod est reformulé en français avec le chemin du champ).
  2. Parcourir récursivement l'objet de contenu et collecter toute chaîne commençant par /images/ ou /og/ → références d'assets. S'y ajoute en production le fichier fixe og/og-default.jpg.
  3. Pour chaque référence : vérifier l'existence du fichier sous public/ ; pour un raster, lire ses métadonnées avec sharp(...).metadata() (format, dimensions, canal alpha, EXIF) et les comparer au tableau 12.10.2 ; pour un SVG, appliquer le contrôle d'innocuité de G-09 (pas de script, pas de lien externe).
  4. Contrôles 12.11.2 à 12.11.5.
  5. Afficher un rapport groupé (erreurs puis avertissements) et sortir avec le code 0 (aucune erreur) ou 1.

Ces contrôles lisent le système de fichiers : ils s'exécutent uniquement au build (dans pnpm build, sous NEXT_PHASE=phase-production-build, ou par le script) et jamais au démarrage ou à l'exécution du conteneur, dont l'image ne contient ni scripts/, ni docs/, ni content-validation.json.

12.11.2 Contrôles d'existence et de conformité #

Code Condition Sévérité
ASSET_MISSING Une référence pointe vers un fichier absent de public/ erreur
ASSET_FORMAT Le format réel du fichier (lu par sharp) ne correspond pas à l'extension attendue du tableau 12.10.2 avertissement en development/staging, erreur en production
ASSET_DIMENSIONS Dimensions inférieures aux minima du tableau 12.10.2, ratio hors tolérance, ou width/height déclarés dans le contenu différents des dimensions réelles avertissement en development/staging, erreur en production
ASSET_ALPHA Un emplacement à canal alpha requis (hero-mid, hero-near, hero-portrait) n'en a pas avertissement en development/staging, erreur en production
ASSET_METADATA Le fichier contient des métadonnées EXIF ou GPS avertissement en development/staging, erreur en production
ASSET_ORPHAN Un fichier de public/images/ (hors dev-fallback/, method/, logo/) n'est référencé par aucun fichier de contenu avertissement en development/staging, erreur en production
ASSET_DEV_FALLBACK Une référence contient dev-fallback avertissement en development/staging, erreur en production
ASSET_SLOT_EMPTY Une référence d'image requise par le contenu est une chaîne vide erreur (le schéma Zod l'interdit déjà ; double filet)
ASSET_WEIGHT Poids d'un fichier de public/images/ > 6 Mo (avertissement) ou > 10 Mo (erreur) ; poids total > 25 Mo (avertissement) ou > 40 Mo (erreur) voir colonne

12.11.3 Budgets de performance #

Le dépôt ne calcule aucun poids de variante : les variantes sont produites à l'exécution. Les budgets de transfert et de JavaScript de la première vue sont mesurés sur le site servi par Lighthouse CI (§14.15.3) et par check:bundle (§15.8) ; le contrôle ci-dessus (ASSET_WEIGHT) ne protège que contre un maître démesuré.

12.11.4 Registre des licences (docs/assets-licences.json) #

Fichier JSON versionné (il doit être présent sur un clone neuf pour que la CI de livraison le contrôle). Obligatoire en APP_ENV=production (absence = ASSET_LICENCE_MISSING, erreur). Structure :

{
  "schemaVersion": 1,
  "visuals": {
    "hero-far":       { "origin": "", "licence": "", "proofRef": "", "verifiedOn": "" },
    "hero-mid":       { "origin": "", "licence": "", "proofRef": "", "verifiedOn": "" },
    "hero-near":      { "origin": "", "licence": "", "proofRef": "", "verifiedOn": "" },
    "hero-mobile":    { "origin": "", "licence": "", "proofRef": "", "verifiedOn": "" },
    "hero-portrait":  { "origin": "", "licence": "", "proofRef": "", "verifiedOn": "" },
    "about-portrait": { "origin": "", "licence": "", "proofRef": "", "verifiedOn": "" }
  }
}
Champ Contenu attendu
clé du visuel Nom du fichier du tableau 12.10.2 sans extension (les six visuels raster ; og-default et le footer vectoriel n'ont pas de ligne)
origin photo-de-malka, creation-originale ou bibliotheque-sous-licence
licence Nom de la licence ou de l'autorisation (usage web commercial couvert)
proofRef Identifiant de facture, numéro de licence ou emplacement du justificatif (la preuve elle-même reste hors dépôt)
verifiedOn Date AAAA-MM-JJ valide, non future

Le contrôle exige, pour chaque visuel raster référencé par le contenu, une entrée dont les quatre champs sont non vides, dont origin appartient à la liste fermée et dont la date est valide (ASSET_LICENCE_INCOMPLETE, erreur en production, avertissement ailleurs). Le dépôt est livré avec le registre complet et les champs vides (développement possible, production bloquée). Le script ne devine aucune valeur.

12.11.5 Garde légale #

Le script ne contrôle pas les champs légaux : cette garde est exécutée par le chargeur lib/content/guards.ts appelé par getContent (§9.3) au build, donc à l'étape 1 ci-dessus et pendant next build. En APP_ENV=production, un champ requis de legal.ts vide fait échouer le build avec la liste des champs manquants. Comme pour les contrôles d'actifs, les gardes qui lisent le système de fichiers (registre des licences, content-validation.json, fichiers d'image) ne s'exécutent jamais à l'exécution du conteneur.

12.12 Documentation du dépôt : README.md #

Décision : un seul fichier README.md (pas de CONTENT.md), rédigé en français, dont la structure est le contrat des 16 rubriques de §18.9.1 (titres H2 identiques, dans l'ordre). La rubrique 5 « Modifier le contenu » reprend le tableau « Je veux changer… → je modifie… » de §18.9.2 : Malka n'écrit pas de code, elle édite des valeurs dans src/content/fr/. La rubrique 16 « Décisions » renvoie vers DECISIONS.md. Précisions propres à ce dépôt, que le README doit contenir :

Rubrique de §18.9.1 Précision obligatoire
3 « Démarrage local » make dev ne lit pas .env (la configuration de développement est dans compose.dev.yaml, §14.5) ; cp .env.example .env n'est requis que pour une pile proche de la production (make up) ; ouvrir http://localhost:3000, pas 127.0.0.1.
5 « Modifier le contenu » Une ligne par fichier de src/content/fr/ ; mentions : ne pas changer les id, les prix sont des nombres entiers en euros, le suffixe + des chiffres est un champ séparé, mettre à jour whatsappE164Digits avec le téléphone, ne pas modifier les ancres de navigation.ts, et — pour un témoignage — passer published: true, consent.status: 'granted', grantedOn et coller le texte mot pour mot (sans cela le site refuse de compiler).
6 « Remplacer les images » Procédure de 12.10 : déposer le fichier maître au nom et au format du tableau 12.10.2 (dimensions minimales, ratio, canal alpha, aucune métadonnée), sous un nouveau nom suffixé -v2 si un fichier existe déjà, mettre à jour le chemin dans le fichier de contenu et la ligne de docs/assets-licences.json, lancer pnpm check:assets, commiter. Aucun script de conversion n'existe.
8 « Déploiement » Renvoi vers le runbook (§14.11) en version courte : make deploy, make rollback TAG=<sha>.
14 « Dépannage » Les erreurs les plus probables et leur correction : build qui échoue sur un champ légal, sur dev-fallback, sur une image non conforme (ASSET_*), sur la licence d'un visuel ; formulaire en 502 ; 429 ; certificat non émis ; e-mail en spam.

Procédure de modification (sans installation locale) : ouvrir le fichier dans l'éditeur web du dépôt Git → modifier → « Commit changes… » sur une nouvelle branche content/<sujet> → ouvrir la Pull Request → attendre la coche verte de la CI (le message d'erreur Zod désigne le fichier et le champ fautifs, en français) → validation par l'opérateur → fusion → déploiement par l'opérateur (make deploy, §14.12). Le README précise qu'aucune modification n'est visible en ligne avant ce déploiement.

12.13 Gestion du code source #

12.13.1 Commits — Conventional Commits #

Format : type(scope)?: sujet, en anglais, impératif, minuscule, sans point final, ≤ 72 caractères. Corps optionnel (pourquoi, pas comment). BREAKING CHANGE: en pied si nécessaire. Le trailer de co-auteur est celui imposé par l'outil de génération du dépôt.

Type Usage
feat Nouvelle fonctionnalité visible
fix Correction de bug
content Modification de src/content/fr/ ou de visuels (type additionnel autorisé par ce projet)
perf Amélioration de performance
refactor Restructuration sans changement de comportement
style Format uniquement (Prettier)
test Ajout/modification de tests
docs README, registre des licences
build Dockerfile, dépendances, Makefile
ci Workflows
chore Maintenance
revert Annulation

Scopes recommandés : nav, hero, philosophy, method, offers, orientation, about, testimonials, contact, footer, legal, seo, motion, a11y, images, content, docker, ci, deps. Exemples : feat(hero): add line-by-line headline reveal, fix(contact): keep submit disabled while pending, content(offers): update lecture-globale price.

12.13.2 Branches et Pull Requests #

  • Branche main : protégée, toujours déployable. Pas de push direct.
  • Branches de travail : feat/<slug>, fix/<slug>, content/<slug>, perf/<slug>, docs/<slug>, chore/<slug>, ci/<slug>. Courtes (< 3 jours ouvrés), rebasées sur main avant fusion.
  • Pull Request obligatoire ; le titre suit Conventional Commits (vérifié par la CI, §14.15) car la fusion se fait en squash, le titre devenant le message du commit.
  • Conditions de fusion : jobs CI quality, unit-integration, e2e (accessibilité incluse), lighthouse, docker-build et security verts (et visual sur main) ; une relecture approuvée (sauf mainteneur unique, §14.15.2) ; discussions résolues ; case « checklist de revue » (§12.14) cochée.
  • Releases : tag vMAJEUR.MINEUR.PATCH sur main ; le tag déclenche aussi le job release-gate (§14.15). Les tags n'entraînent aucun déploiement automatique.

12.14 Checklist de revue de code #

À copier dans .github/pull_request_template.md sous forme de cases à cocher. Le relecteur n'approuve que si toutes les lignes applicables sont vraies.

  1. Périmètre : la PR ne contient que ce que son titre annonce ; aucune fonctionnalité hors périmètre (pas de paiement, base de données, CMS, i18n visible, blog, pistage).
  2. Contenu : aucun texte visiteur, prix, numéro ou chiffre en dur hors src/content/fr/ ; aucun contenu inventé (témoignage, chiffre, mention légale).
  3. Serveur / client : nouveau 'use client' justifié ; aucun module serveur importé côté client ; props sérialisables et minimales.
  4. Types : aucun any, @ts-ignore, assertion non nécessaire ; entrées externes validées par Zod.
  5. Style : tokens uniquement, aucune couleur arbitraire, cn() utilisé, mobile-first, aucun dépassement horizontal à 320 px.
  6. Accessibilité : navigation clavier complète, focus visible, noms accessibles, contrastes AA, cibles ≥ 24 px (44 px pour les actions primaires tactiles), lang, ordre de lecture, annonce des états dynamiques.
  7. Mouvement : seuls transform/opacity/filter/clip-path animés ; prefers-reduced-motion respecté ; transformations de survol uniquement via .motion-* ; effets lourds désactivés sous 1024 px et en pointeur grossier.
  8. Performance : pas de dépendance ajoutée sans mesure de l'impact sur le JS de première charge (pnpm check:bundle) ; images via next/image avec sizes et alt ; polices via next/font.
  9. Sécurité et RGPD : aucun secret, aucun log de donnée personnelle, pas de dangerouslySetInnerHTML, pas de script tiers non prévu, pas de cookie.
  10. Tests : tests unitaires/intégration pour la logique ajoutée ; E2E mis à jour si un parcours change ; tout bug corrigé a un test de non-régression.
  11. Docker / CI : docker build et docker compose config OK si Dockerfile/compose*.yaml/Caddyfile touchés ; aucune variable d'environnement ajoutée sans mise à jour de .env.example et de §13.
  12. Documentation : README ou registre des licences mis à jour si la procédure change.

12.15 Critères d'acceptation (§12) #

ID Critère Vérification
AC-12.2-01 Tous les fichiers et dossiers de l'arborescence §12.2 existent à la livraison (hors fichiers (G) produits par les outils, fichiers (M) fournis par Malka et dossiers non versionnés) et aucun dossier de premier niveau supplémentaire n'a été créé ; chaque chemin src/…, scripts/…, tests/…, docs/… cité dans une autre section du cahier des charges figure dans l'arborescence. Test tests/unit/tooling/tree.test.ts : liste de chemins attendus (existsSync) et liste blanche des dossiers de premier niveau (readdirSync('.')). Exécuté dans le job quality (§14.15), pas dans l'étape test du Dockerfile.
AC-12.2-02 src/app ne contient aucun composant réutilisable ; chaque src/app/**/page.tsx est un Server Component. ESLint + revue.
AC-12.3-01 Importer @/lib/email, @/lib/env, @/lib/log, @/lib/rate-limit, @/lib/server, @/lib/seo ou server-only depuis src/components/** ou src/hooks/** fait échouer pnpm lint ; importer @/lib/content ou @/lib/booking depuis un îlot client ou un hook fait échouer pnpm lint, alors que les composants serveur *-section.tsx peuvent le faire. Test lint-rules.test.ts.
AC-12.3-02 Aucun import ../ dans src/ ; l'alias @/ résout dans tsc, Vitest, tsx et Next.js. pnpm lint, pnpm typecheck, pnpm test, pnpm check:assets.
AC-12.3-03 Les modules marqués server-only en §12.3.2 ont import 'server-only' en première ligne. Test unitaire qui lit les fichiers et vérifie la première ligne de code.
AC-12.4-01 Tous les noms de fichiers de src/, tests/, scripts/ sont en kebab-case sans accent ; les identifiants de contenu sont en kebab-case français sans accent. Test unitaire qui parcourt src/, tests/, scripts/ (regex ^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+)+$ pour les fichiers) + schémas Zod (regex ^[a-z0-9]+(-[a-z0-9]+)*$ pour les identifiants).
AC-12.5-01 Le seul usage de 'use client' est dans les fichiers marqués (C) en §12.2 (liste clientIslandFiles de §12.8.1 identique) ; le JS de première charge de / ≤ 170 Ko gzip. Test unitaire de liste blanche + check:bundle et Lighthouse CI (§14.15.3).
AC-12.5-02 Aucun <img> brut dans src/ ; aucun dangerouslySetInnerHTML hors lib/seo/json-ld.tsx. ESLint (@next/next/no-img-element, react/no-danger, jsx-a11y/alt-text) + recherche dans le test d'arbre.
AC-12.6-01 Une classe bg-[#…] ou un littéral #rrggbb dans un .tsx fait échouer pnpm lint (sauf src/lib/design/tokens.ts) ; les valeurs de globals.css correspondent aux tokens de §5 ; un hover:-translate-y-1 fait échouer pnpm lint. lint-rules.test.ts.
AC-12.6-02 cn() combine clsx et tailwind-merge : cn('p-2', false, 'p-4') === 'p-4'. Test unitaire cn.test.ts.
AC-12.7-01 tsconfig.json active strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch ; pnpm typecheck passe sans erreur. Test qui parse tsconfig.json + CI.
AC-12.8-01 pnpm lint échoue sur un <img> sans alt, un tabIndex={1}, un onClick sur <div> sans rôle/clavier et un test.only. lint-rules.test.ts.
AC-12.8-02 pnpm format:check passe sur le dépôt entier à la livraison. CI.
AC-12.8-03 Il n'existe qu'un fichier de configuration par outil : eslint.config.mjs, playwright.config.ts, vitest.config.ts, lighthouserc.json. Test d'arbre (aucun lighthouserc.* autre, aucun .eslintrc*).
AC-12.9-01 Les 24 scripts de §12.9 existent avec les commandes exactes indiquées (aucun script images) ; pnpm verify passe. Test unitaire sur package.json + CI.
AC-12.10-01 Aucun script de conversion d'images, aucun manifeste d'images, aucun dossier assets-src/ ni fichier suffixé par une largeur n'existe dans le dépôt ; chaque visuel du tableau 12.10.2 est référencé par un seul fichier. Test d'arbre + check:assets.
AC-12.10-02 La configuration images de next.config.ts contient exactement formats ['image/avif','image/webp'], deviceSizes [640,828,1080,1280,1920,2560], qualities [60,75] et un localPatterns limité à /images/**. Test unitaire sur la configuration importée.
AC-12.11-01 En APP_ENV=production, pnpm check:assets échoue (code 1) si une référence contient dev-fallback, si un fichier référencé manque, s'il est trop petit, hors ratio, sans alpha requis ou porteur d'EXIF, ou si le registre docs/assets-licences.json est incomplet. En development, ces cas (hors fichier manquant) sont des avertissements. tests/unit/tooling/check-assets.test.ts (images factices générées avec sharp).
AC-12.11-02 pnpm build en APP_ENV=production avec un champ de legal.ts vide échoue en listant les champs manquants ; le conteneur démarré en production ne lit ni docs/, ni content-validation.json, ni scripts/. tests/integration/content-build-guards.test.ts + job docker-build (§14.15).
AC-12.12-01 README.md contient les 16 titres de §18.9.1 dans l'ordre et le tableau de §18.9.2 ; chaque fichier de src/content/fr/ débute par un en-tête en français. Test unitaire de structure.
AC-12.13-01 Le titre d'une PR non conforme à Conventional Commits fait échouer le job quality. CI (§14.15).
AC-12.14-01 .github/pull_request_template.md reprend les 12 points de §12.14 en cases à cocher. Revue.

13. Configuration et variables d'environnement #

Cette section est le seul endroit qui liste toutes les variables d'environnement du projet. Le code applicatif ne lit que les variables du §13.2 ; les variables d'orchestration (Compose, Makefile, CI, outils) du §13.3 ne sont jamais lues par l'application.

13.1 Principes #

  1. Deux phases. Les variables de build sont fournies à la construction de l'image (--build-arg) ; elles sont figées dans le HTML statique, les en-têtes de sécurité et le site (changer = reconstruire). Les variables d'exécution sont lues au démarrage du conteneur (changer = recréer le conteneur, sans reconstruction). Les variables de build sont non secrètes et sont recopiées dans l'environnement de l'image finale (le serveur valide le même jeu de valeurs qu'au build). Le service web de Compose ne les redéclare jamais : sa liste environment: est explicite et ne contient que des variables d'exécution (§13.6), si bien qu'une valeur de build modifiée dans .env ne peut pas diverger de celle qui est figée dans l'image.
  2. Aucun secret en build. BREVO_API_KEY et LOG_SALT ne sont ni ARG ni ENV du Dockerfile (§10.4).
  3. Aucune variable NEXT_PUBLIC_*. Les valeurs de build utiles au navigateur lui sont transmises en props par des composants serveur (§9.2.3). Un test recherche ce préfixe dans src/ (0 occurrence).
  4. Une seule lecture. Seuls src/lib/env.ts, src/instrumentation.ts (variables internes de Next.js), next.config.ts, les scripts (scripts/**), les tests (tests/**) et les fichiers de configuration d'outils (*.config.*) lisent process.env (règle ESLint, §12.1). Les gardes de contenu (src/lib/content/guards.ts) et le constructeur d'en-têtes (src/lib/security/headers.ts) reçoivent l'environnement en argument ; scripts/check-assets.ts lit APP_ENV lui-même (énumération locale) et n'importe pas src/lib/env.ts, marqué server-only.
  5. Échec explicite. Une variable manquante ou invalide fait échouer le build ou le démarrage avec un message qui nomme la variable, jamais sa valeur (§13.4).
  6. Valeurs vides. Pour toutes les variables, une valeur vide (VAR=) équivaut à une variable absente : Compose transmet ainsi une variable non définie sous forme de chaîne vide (§13.6) sans masquer la valeur par défaut de l'application. Pour les booléens, seules les valeurs true et false sont admises (1 et yes sont rejetées).
  7. Un seul tableau. Les tableaux de ce chapitre (§13.2 pour l'application, §13.3 pour l'orchestration et l'outillage) sont les seules listes de variables du document : toute autre section renvoie ici au lieu de les répéter.

Environnements. APP_ENV distingue trois environnements applicatifs ; le mode « test » n'en est pas un (les tests unitaires et d'intégration s'exécutent avec APP_ENV=development et NODE_ENV=test).

Environnement APP_ENV Gardes de build EMAIL_DRY_RUN par défaut Indexation
Développement et tests development Avertissements ; visuels et textes provisoires tolérés true Fermée (§11.1.7)
Préproduction staging Avertissements ; SITE_URL obligatoire true Fermée (§11.1.7)
Production production Toutes bloquantes (§9.3.7, §1.3.3) false ; true interdit (le démarrage échoue) Autorisée

13.2 Tableau des variables de l'application #

Légende : B = build (argument de build, non secret) ; R = exécution ; dév / stag / prod = valeur requise ou non selon APP_ENV (development, staging, production).

Variable Phase Requise (dév / stag / prod) Défaut Exemple Secret Module consommateur Règle de validation
APP_ENV B (copiée en R) non / non / oui* development (code) ; production (argument du Dockerfile) production non env.ts, security/headers.ts ; reçue en argument par content/guards.ts development, staging ou production. *L'image fixe production par défaut : un build sans valeur applique donc les gardes de production
SITE_URL B (copiée en R) non / oui / oui http://localhost:3000 (développement uniquement) https://example.com non security/headers.ts, server/origin-guard.ts, seo/*, sitemap.ts, robots.ts URL absolue http(s), sans identifiants, sans chemin, requête ni fragment ; barre finale retirée (refusée en production) ; en production : https obligatoire, hôte ni localhost, ni adresse IP, ni domaine réservé d'exemple (§13.4)
ANALYTICS_PROVIDER B (copiée en R) non / non / non none umami non analytics/umami-script.tsx, security/headers.ts none ou umami (toute autre valeur échoue : §10.6.4)
UMAMI_SCRIPT_URL B (copiée en R) si umami — https://stats.example.com/script.js non idem URL https sans identifiants ; ignorée si le fournisseur est none (garde G-10)
UMAMI_WEBSITE_ID B (copiée en R) si umami — 3b1c9d0e-5f2a-4c6b-9a1d-7e8f0a2b4c6d non (identifiant public) idem UUID ; ignorée si le fournisseur est none (garde G-10)
APP_VERSION B (copiée en R) non / non / non local a1b2c3d non contact/handler, route /api/health, journaux, étiquettes d'image ^[A-Za-z0-9._-]{1,40}$ ; SHA Git court calculé par le Makefile ou la CI (§14.7) ; la valeur local en production déclenche un avertissement au démarrage
BREVO_API_KEY R non / si envoi réel / oui — (vide dans le modèle) oui email/brevo.ts ≥ 20 caractères, sans espace ; requise dès que EMAIL_DRY_RUN vaut false
EMAIL_FROM_ADDRESS R non / si envoi réel / oui no-reply@example.com (hors production, mode à blanc) contact@example.com non email/* Adresse valide ≤ 254 caractères ; en production : domaine authentifié dans Brevo (SPF, DKIM, DMARC), jamais une messagerie grand public (liste du §13.4), jamais un domaine réservé d'exemple
EMAIL_FROM_NAME R non Malka Debleds Malka Debleds non email/* 2 à 60 caractères, sans <, >, " ni caractère de contrôle
CONTACT_TO_EMAIL R non astheoris.pro@gmail.com astheoris.pro@gmail.com non email/* Adresse valide ≤ 254 caractères ; doit rester égale à site.contact.email tant que Malka ne décide pas d'une autre boîte (test de cohérence de §15)
SEND_ACKNOWLEDGEMENT R non true true non contact/handler.ts true ou false ; false = interrupteur d'urgence (§10.10)
EMAIL_DRY_RUN R non true en développement, en test et en préproduction ; false en production false non email/index.ts true ou false ; true interdit en production (le démarrage échoue : les messages seraient perdus alors que le formulaire afficherait un succès)
TRUST_PROXY R non true true non server/client-ip.ts true ou false ; false si le conteneur est exposé sans proxy (§9.6.5)
LOG_LEVEL R non info info non log/logger.ts debug, info, warn ou error
LOG_SALT R non / si envoi réel / oui valeur factice de développement (hors production, mode à blanc) (vide dans le modèle ; générer : openssl rand -hex 32) oui log/logger.ts, rate-limit/* ≥ 32 caractères, sans espace
PORT R non 3000 3000 non serveur Next.js standalone, HEALTHCHECK Entier de 1 à 65535 (fixé à 3000 par l'image)
HOSTNAME R non 0.0.0.0 0.0.0.0 non serveur Next.js standalone Nom d'hôte ou adresse non vide ; fixé à 0.0.0.0 par l'image et par Compose (Docker affecte sinon le nom du conteneur, ce qui empêche l'écoute sur toutes les interfaces, §14.2)

Variables posées par l'image ou par l'outil, ne jamais les définir à la main : NODE_ENV=production (image de production ; exposée par env.ts pour le mode de test du logger), NEXT_TELEMETRY_DISABLED=1 (Dockerfile, §14.2), NEXT_RUNTIME et NEXT_PHASE (internes à Next.js, lues par src/instrumentation.ts seulement). Aucune garde qui lit le système de fichiers (§9.3.7) n'est exécutée au runtime du conteneur : elles tournent au build, par scripts/check-assets.ts.

Variable réservée, non lue : CALCOM_LINK (URL https de réservation Cal.com). Elle n'est ni dans le schéma ni dans le modèle ; elle sera ajoutée au tableau ci-dessus par l'étape 2 de §9.9.2.

13.3 Variables d'orchestration et d'outillage (hors application) #

Lues par Compose, Caddy, le Makefile, la CI ou les outils de build et de test ; jamais par le code de src/ et jamais validées par Zod. Ce tableau est la liste complète : une variable absente de §13.2 et de ce tableau n'existe pas dans le projet. Détail d'usage : §14.8.

Variable Lue par Défaut Requise Rôle
COMPOSE_PROFILES Compose vide non proxy active Caddy ; ne jamais y mettre e2e
COMPOSE_FILE Compose vide non compose.yaml:compose.hostport.yaml avec un proxy existant sur l'hôte
SITE_DOMAIN Caddyfile, Makefile — avec le profil proxy Domaine apex sans schéma ; SITE_URL doit valoir https://$SITE_DOMAIN
ACME_EMAIL Caddyfile — avec le profil proxy Contact Let's Encrypt
ACME_CA Caddyfile autorité de production non Autorité de test pour un essai à blanc
WEB_PORT compose.hostport.yaml 3000 non Port local de publication (boucle locale uniquement)
VCS_URL Compose vide non Étiquette OCI source
SOURCE_COMMIT Compose (argument de build), Coolify posée par Coolify (SHA Git complet) non Repli de APP_VERSION quand le Makefile n'est pas utilisé
APP_VERSION (côté Makefile) Makefile → Compose SHA Git court calculé, jamais écrit dans .env non Étiquette de l'image et argument de build correspondant à la variable de build de §13.2
HOST_UID, HOST_GID Makefile, CI id -u, id -g non Propriétaire des rapports Playwright
PLAYWRIGHT_BASE_URL playwright.config.ts, service e2e http://localhost:3000 hors Compose ; http://web-e2e:3000 dans le service e2e non Cible des tests de bout en bout sur le contenu livré
PLAYWRIGHT_FIXTURES_URL playwright.config.ts, service e2e http://web-e2e-fixtures:3000 dans le service e2e pour les tests qui exigent des témoignages publiables et des mentions légales complètes Cible du service construit à partir du stade runner-fixtures
PLAYWRIGHT_BROWSERS, PW_ALL_BROWSERS Compose chromium, 0 non Matrice de navigateurs (§15)
PLAYWRIGHT_BROWSERS_PATH image de test /ms-playwright non Emplacement des navigateurs
LHCI_BASE_URL lighthouserc.json, @lhci/cli http://localhost:3000 non Cible des mesures Lighthouse CI
CI CI, Vitest, Playwright — posée par la CI Active les modes non interactifs
COREPACK_ENABLE_DOWNLOAD_PROMPT Corepack 0 non Désactive l'invite de téléchargement dans les images
NODE_IMAGE Dockerfile (ARG) ligne d'image Node.js de §9.1 non Image de base des étapes de build et d'exécution
NODE_OPTIONS Dockerfile (étape builder) --max-old-space-size=1536 non Plafond de mémoire du build (§14.2)
NEXT_TELEMETRY_DISABLED Dockerfile, Compose 1 non Désactive la télémétrie de Next.js
WATCHPACK_POLLING compose.dev.yaml false non true sur macOS/Windows pour le rechargement à chaud
COMPOSE, KEEP_IMAGES, SERVICE, TAG Makefile docker compose, 3, vide, vide non Commande Compose, nombre d'images conservées par make prune, service ciblé par make logs, étiquette de retour arrière de make rollback (§14.7)
PRODUCTION_SITE_URL CI (variable de dépôt) — pour le déploiement CI Valeur de SITE_URL du build de production en CI
ANALYTICS_PROVIDER, UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID (variables de dépôt) CI (job release-gate) — pour le build de production en CI Valeurs de build de production transmises en arguments de build ; mêmes règles que §13.2

13.4 Analyse et validation : src/lib/env.ts #

Règles complémentaires :

  • SITE_URL en production : https, hôte ni localhost, ni adresse IP (IPv4 ou IPv6), ni example.com, example.org, example.net, ni TLD ou suffixe réservé (.example, .test, .invalid, .localhost) ; pas de barre finale dans la valeur brute (garde G-06). Hors production, la barre finale est retirée silencieusement.
  • Analytique : si ANALYTICS_PROVIDER=umami, UMAMI_SCRIPT_URL (URL https valide, sans identifiants) et UMAMI_WEBSITE_ID (UUID) sont obligatoires (garde G-10) ; si none, ces deux variables sont ignorées.
  • E-mail : EMAIL_DRY_RUN vaut par défaut true en development (tests compris) et en staging, et false en production, où la valeur true est une erreur de configuration (le processus s'arrête au démarrage ; un essai d'envoi simulé se fait sur une préproduction). Si l'envoi est réel (EMAIL_DRY_RUN=false), BREVO_API_KEY, EMAIL_FROM_ADDRESS et LOG_SALT sont requis, quel que soit APP_ENV. En production, EMAIL_DRY_RUN=true est une erreur de configuration ; EMAIL_FROM_ADDRESS ne peut pas appartenir à un domaine de messagerie grand public : gmail.com, googlemail.com, yahoo.com, yahoo.fr, outlook.com, outlook.fr, hotmail.com, hotmail.fr, live.com, live.fr, msn.com, icloud.com, me.com, orange.fr, wanadoo.fr, free.fr, sfr.fr, laposte.net, proton.me, protonmail.com, gmx.com, gmx.fr, aol.com.
  • LOG_SALT : requis en production et pour tout envoi réel ; sinon valeur factice de développement.
  • Version : en production, APP_VERSION=local produit l'avertissement ⚠ APP_VERSION vaut « local » (non bloquant).
// src/lib/env.ts
import 'server-only';
import { z } from 'zod';

export type AppEnv = 'development' | 'staging' | 'production';

export interface BuildEnv {
  APP_ENV: AppEnv;
  SITE_URL: string;                              // origine normalisée, sans barre finale
  ANALYTICS_PROVIDER: 'none' | 'umami';
  UMAMI_SCRIPT_URL?: string;
  UMAMI_WEBSITE_ID?: string;
  APP_VERSION: string;
}
export interface ServerEnv extends BuildEnv {
  NODE_ENV: string;
  BREVO_API_KEY?: string;
  EMAIL_FROM_ADDRESS: string;
  EMAIL_FROM_NAME: string;
  CONTACT_TO_EMAIL: string;
  SEND_ACKNOWLEDGEMENT: boolean;
  EMAIL_DRY_RUN: boolean;
  TRUST_PROXY: boolean;
  LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
  LOG_SALT: string;
  PORT: number;
  HOSTNAME: string;
}
export interface EnvIssue { name: string; message: string; severity?: 'error' | 'warning' }
export type EnvResult<T> = { ok: true; env: T; warnings: EnvIssue[] } | { ok: false; issues: EnvIssue[] };

const FREEMAIL = new Set([
  'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'outlook.com', 'outlook.fr', 'hotmail.com', 'hotmail.fr',
  'live.com', 'live.fr', 'msn.com', 'icloud.com', 'me.com', 'orange.fr', 'wanadoo.fr', 'free.fr', 'sfr.fr',
  'laposte.net', 'proton.me', 'protonmail.com', 'gmx.com', 'gmx.fr', 'aol.com',
]);
const RESERVED_HOST = /(^|\.)(example\.(com|org|net)|localhost)$|\.(example|test|invalid|localhost)$/i;
const IP_HOST = /^(\d{1,3}\.){3}\d{1,3}$|^\[?[0-9a-f:]+\]?$/i;
const DEV_SALT = 'dev-only-salt-not-a-secret-0000000000';

const blank = (v: unknown) => (typeof v === 'string' && v.trim() === '' ? undefined : v);
const text = <T extends z.ZodType>(s: T) => z.preprocess(blank, s.optional());
const flag = z.preprocess(blank, z.enum(['true', 'false'], { error: "doit valoir « true » ou « false »" }).transform((v) => v === 'true').optional());

const baseSchema = z.object({
  APP_ENV: text(z.enum(['development', 'staging', 'production'], { error: 'doit valoir development, staging ou production' })),
  SITE_URL: text(z.string()),
  ANALYTICS_PROVIDER: text(z.enum(['none', 'umami'], { error: "doit valoir « none » ou « umami »" })),
  UMAMI_SCRIPT_URL: text(z.string()),
  UMAMI_WEBSITE_ID: text(z.string()),
  APP_VERSION: text(z.string().regex(/^[A-Za-z0-9._-]{1,40}$/, { error: 'format invalide (lettres, chiffres, . _ -, 40 maximum)' })),
  BREVO_API_KEY: text(z.string().min(20, { error: '20 caractères minimum' }).regex(/^\S+$/, { error: 'ne doit contenir aucun espace' })),
  EMAIL_FROM_ADDRESS: text(z.email({ error: 'adresse e-mail invalide' }).max(254)),
  EMAIL_FROM_NAME: text(z.string().min(2).max(60).regex(/^[^<>"\u0000-\u001F]+$/, { error: 'caractères interdits (< > " ou contrôle)' })),
  CONTACT_TO_EMAIL: text(z.email({ error: 'adresse e-mail invalide' }).max(254)),
  SEND_ACKNOWLEDGEMENT: flag,
  EMAIL_DRY_RUN: flag,
  TRUST_PROXY: flag,
  LOG_LEVEL: text(z.enum(['debug', 'info', 'warn', 'error'], { error: 'doit valoir debug, info, warn ou error' })),
  LOG_SALT: text(z.string().min(32, { error: '32 caractères minimum' }).regex(/^\S+$/, { error: 'ne doit contenir aucun espace' })),
  PORT: text(z.string().regex(/^\d{1,5}$/, { error: 'entier de 1 à 65535' }).refine((v) => +v >= 1 && +v <= 65535, { error: 'entier de 1 à 65535' })),
  HOSTNAME: text(z.string().min(1).max(253).regex(/^[A-Za-z0-9.:\[\]-]+$/, { error: "nom d'hôte invalide" })),
  NODE_ENV: text(z.string()),
});
const KEYS = Object.keys(baseSchema.shape);

function pickRaw(source: Record<string, string | undefined>) {
  return Object.fromEntries(KEYS.map((k) => [k, source[k]]));
}

function collect(source: Record<string, string | undefined>, issues: EnvIssue[]) {
  const r = baseSchema.safeParse(pickRaw(source));
  if (!r.success) for (const i of r.error.issues) issues.push({ name: String(i.path[0] ?? '(env)'), message: i.message });
  return r.success ? r.data : null;     // les messages Zod ne contiennent jamais la valeur fournie
}

function buildPart(v: z.output<typeof baseSchema>, issues: EnvIssue[], warnings: EnvIssue[]): BuildEnv {
  const appEnv: AppEnv = v.APP_ENV ?? 'development';
  const prod = appEnv === 'production';
  let siteUrl = 'http://localhost:3000';
  if (v.SITE_URL === undefined) {
    if (appEnv !== 'development') issues.push({ name: 'SITE_URL', message: `obligatoire hors développement` });
  } else {
    try {
      const u = new URL(v.SITE_URL);
      if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('protocol');
      if (u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) throw new Error('shape');
      if (prod) {
        if (u.protocol !== 'https:') issues.push({ name: 'SITE_URL', message: 'doit commencer par https:// en production' });
        if (v.SITE_URL.endsWith('/')) issues.push({ name: 'SITE_URL', message: 'ne doit pas se terminer par « / »' });
        if (RESERVED_HOST.test(u.hostname) || IP_HOST.test(u.hostname))
          issues.push({ name: 'SITE_URL', message: 'doit désigner le domaine public définitif (ni localhost, ni adresse IP, ni domaine d\'exemple)' });
      }
      siteUrl = u.origin;
    } catch {
      issues.push({ name: 'SITE_URL', message: 'URL absolue http(s) attendue, sans identifiants, chemin, requête ni fragment' });
    }
  }
  const analytics = v.ANALYTICS_PROVIDER ?? 'none';
  let umamiUrl: string | undefined; let umamiId: string | undefined;
  if (analytics === 'umami') {
    try {
      const u = new URL(v.UMAMI_SCRIPT_URL ?? '');
      if (u.protocol !== 'https:' || u.username || u.password) throw new Error('shape');
      umamiUrl = u.toString();
    } catch { issues.push({ name: 'UMAMI_SCRIPT_URL', message: 'obligatoire avec ANALYTICS_PROVIDER=umami : URL https valide' }); }
    if (!z.uuid().safeParse(v.UMAMI_WEBSITE_ID).success) issues.push({ name: 'UMAMI_WEBSITE_ID', message: 'obligatoire avec ANALYTICS_PROVIDER=umami : UUID valide' });
    else umamiId = v.UMAMI_WEBSITE_ID;
  }
  const appVersion = v.APP_VERSION ?? 'local';
  if (prod && appVersion === 'local') warnings.push({ name: 'APP_VERSION', message: 'vaut « local » en production', severity: 'warning' });
  return { APP_ENV: appEnv, SITE_URL: siteUrl, ANALYTICS_PROVIDER: analytics, UMAMI_SCRIPT_URL: umamiUrl, UMAMI_WEBSITE_ID: umamiId, APP_VERSION: appVersion };
}

/** Variables de build uniquement (appelée pendant `next build`, où les secrets d'exécution sont absents). */
export function parseBuildEnv(source: Record<string, string | undefined>): EnvResult<BuildEnv> {
  const issues: EnvIssue[] = []; const warnings: EnvIssue[] = [];
  const v = collect(source, issues);
  const env = v ? buildPart(v, issues, warnings) : null;
  return issues.length === 0 && env ? { ok: true, env, warnings } : { ok: false, issues };
}

/** Jeu complet (démarrage du serveur). Fonction pure, sans effet de bord : testée sans `process.exit`. */
export function parseEnv(source: Record<string, string | undefined>): EnvResult<ServerEnv> {
  const issues: EnvIssue[] = []; const warnings: EnvIssue[] = [];
  const v = collect(source, issues);
  if (!v) return { ok: false, issues };
  const build = buildPart(v, issues, warnings);
  const prod = build.APP_ENV === 'production';
  const dryRun = v.EMAIL_DRY_RUN ?? !prod;
  if (prod && dryRun) issues.push({ name: 'EMAIL_DRY_RUN', message: 'interdit en production (les demandes de contact seraient perdues)' });
  const realSend = !dryRun;
  const required = (name: string, value: unknown, when: boolean) => {
    if (when && value === undefined) issues.push({ name, message: prod ? 'obligatoire en production' : "obligatoire quand l'envoi d'e-mail est réel" });
  };
  required('BREVO_API_KEY', v.BREVO_API_KEY, realSend);
  required('EMAIL_FROM_ADDRESS', v.EMAIL_FROM_ADDRESS, prod || realSend);
  required('LOG_SALT', v.LOG_SALT, prod || realSend);
  const from = v.EMAIL_FROM_ADDRESS ?? 'no-reply@example.com';
  if (prod && v.EMAIL_FROM_ADDRESS) {
    const domain = from.split('@')[1]!.toLowerCase();
    if (FREEMAIL.has(domain)) issues.push({ name: 'EMAIL_FROM_ADDRESS', message: 'le domaine doit être celui du site (authentifié dans Brevo), pas une messagerie grand public' });
    else if (RESERVED_HOST.test(domain)) issues.push({ name: 'EMAIL_FROM_ADDRESS', message: "le domaine ne peut pas être un domaine d'exemple" });
  }
  if (issues.length > 0) return { ok: false, issues };
  return {
    ok: true,
    warnings,
    env: {
      ...build,
      NODE_ENV: v.NODE_ENV ?? 'production',
      BREVO_API_KEY: v.BREVO_API_KEY,
      EMAIL_FROM_ADDRESS: from,
      EMAIL_FROM_NAME: v.EMAIL_FROM_NAME ?? 'Malka Debleds',
      CONTACT_TO_EMAIL: v.CONTACT_TO_EMAIL ?? 'astheoris.pro@gmail.com',
      SEND_ACKNOWLEDGEMENT: v.SEND_ACKNOWLEDGEMENT ?? true,
      EMAIL_DRY_RUN: dryRun,
      TRUST_PROXY: v.TRUST_PROXY ?? true,
      LOG_LEVEL: v.LOG_LEVEL ?? 'info',
      LOG_SALT: v.LOG_SALT ?? DEV_SALT,
      PORT: v.PORT ? Number(v.PORT) : 3000,
      HOSTNAME: v.HOSTNAME ?? '0.0.0.0',
    },
  };
}

/** Rapport lisible : toutes les variables fautives en un seul message, jamais une valeur. */
export function formatEnvIssues(issues: EnvIssue[], appEnv: string): string {
  return [
    `✖ Configuration invalide (APP_ENV=${appEnv}) — le serveur ne démarre pas.`,
    '  Variables à corriger :',
    ...issues.map((i) => `    - ${i.name} — ${i.message}`),
    "Aucune valeur n'est affichée. Voir le modèle .env.example.",
  ].join('\n');
}

export class EnvValidationError extends Error {
  constructor(public readonly issues: EnvIssue[], appEnv: string) { super(formatEnvIssues(issues, appEnv)); }
}

let cachedBuild: BuildEnv | undefined;
let cachedServer: ServerEnv | undefined;

/** Pour le layout et les pages au build : lève EnvValidationError (le build échoue). */
export function getBuildEnv(): BuildEnv {
  if (cachedBuild) return cachedBuild;
  const r = parseBuildEnv(process.env);
  if (!r.ok) throw new EnvValidationError(r.issues, process.env.APP_ENV ?? 'development');
  return (cachedBuild = r.env);
}

/** Pour les modules serveur à l'exécution. Appelle d'abord loadEnvOrExit() au démarrage (instrumentation). */
export function getServerEnv(): ServerEnv {
  if (cachedServer) return cachedServer;
  const r = parseEnv(process.env);
  if (!r.ok) throw new EnvValidationError(r.issues, process.env.APP_ENV ?? 'development');
  return (cachedServer = r.env);
}

/** Démarrage du serveur (src/instrumentation.ts) : journalise `app.env_invalid` puis quitte avec le code 1. */
export function loadEnvOrExit(): ServerEnv {
  const r = parseEnv(process.env);
  if (r.ok) { cachedServer = r.env; return r.env; }
  const appEnv = process.env.APP_ENV ?? 'development';
  process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), level: 'error', event: 'app.env_invalid', appEnv, missing: r.issues.map((i) => i.name) }) + '\n');
  process.stderr.write(formatEnvIssues(r.issues, appEnv) + '\n');
  process.exit(1);
}

Comportement au démarrage : src/instrumentation.ts appelle loadEnvOrExit() (§9.7.4). En production, une configuration invalide arrête le processus avant toute écoute réseau ; le conteneur se redémarre (politique unless-stopped) et le contrôle de santé échoue, ce qui rend l'erreur visible (docker compose ps, docker compose logs web). Pendant next build, NEXT_PHASE=phase-production-build désactive cette étape ; seul getBuildEnv() s'exécute, via le layout.

13.5 Modèle .env.example #

Fichier commité à la racine. Aucune valeur secrète ; domaine d'exemple example.com (refusé par les gardes en production : il doit être remplacé). Les valeurs ne sont jamais entourées de guillemets (§14.7) ; les booléens sont écrits true / false.

# ═══════════════════════════════════════════════════════════════════════════
# Malka Debleds — Entreprise vivante · modèle d'environnement
#
#   cp .env.example .env && chmod 600 .env
#
# • Ne jamais committer .env. Ne jamais entourer les valeurs de guillemets.
# • BUILD   : lu pendant la construction de l'image. Modifier = reconstruire.
# • RUNTIME : lu au démarrage du conteneur. Modifier = recréer le conteneur
#             (docker compose up -d --force-recreate web).
# • Les secrets (BREVO_API_KEY, LOG_SALT) ne se saisissent QUE ici ou dans les
#   variables d'exécution de Coolify. Ne les collez jamais ailleurs.
# • Ce fichier est lu par l'interpolation de Compose, pas par un shell : aucune
#   substitution « $(…) » n'y est évaluée.
# ═══════════════════════════════════════════════════════════════════════════

# ── Site (BUILD) ───────────────────────────────────────────────────────────
# development | staging | production. En production, les gardes de contenu
# et d'actifs sont bloquantes (mentions légales, visuels réels, licences…).
APP_ENV=production
# URL publique définitive : https, sans barre finale. "example.com" est refusé
# en production : remplacez-le par votre domaine.
SITE_URL=https://example.com
# Version affichée par /api/health et utilisée pour les étiquettes d'image.
# Calculée automatiquement (SHA Git court) : laissez cette ligne commentée.
# APP_VERSION=

# ── Mesure d'audience (BUILD) — désactivée par défaut ──────────────────────
# none | umami. Umami sans cookie uniquement (voir la politique de confidentialité).
ANALYTICS_PROVIDER=none
# Obligatoires seulement avec ANALYTICS_PROVIDER=umami :
# UMAMI_SCRIPT_URL=https://stats.example.com/script.js
# UMAMI_WEBSITE_ID=00000000-0000-4000-8000-000000000000

# ── E-mail transactionnel Brevo (RUNTIME) ──────────────────────────────────
# SECRET. Clé API transactionnelle dédiée à ce site. Laisser vide dans le dépôt.
BREVO_API_KEY=
# Adresse d'expédition sur un domaine authentifié dans Brevo (SPF, DKIM, DMARC).
# Une messagerie grand public (gmail.com, outlook.com…) est refusée en production.
EMAIL_FROM_ADDRESS=contact@example.com
# Nom affiché de l'expéditeur.
EMAIL_FROM_NAME=Malka Debleds
# Boîte qui reçoit les demandes de contact.
CONTACT_TO_EMAIL=astheoris.pro@gmail.com
# true : le visiteur reçoit un accusé de réception. false : interrupteur d'urgence.
SEND_ACKNOWLEDGEMENT=true
# true : aucun e-mail n'est envoyé (essais en développement ou en préproduction).
# INTERDIT en production (le serveur refuse de démarrer). Laisser false en production.
EMAIL_DRY_RUN=false

# ── Réseau et journaux (RUNTIME) ───────────────────────────────────────────
# true derrière Caddy ou le proxy de Coolify ; false si le conteneur est exposé directement.
TRUST_PROXY=true
# debug | info | warn | error
LOG_LEVEL=info
# SECRET. Sel du pseudonyme d'adresse IP. Générer la valeur dans un terminal avec
# « openssl rand -hex 32 », puis coller le résultat après le signe égal : ce fichier
# n'évalue aucune substitution de commande. Un « $ » doit y être doublé (« $$ »).
LOG_SALT=
# Fixés par l'image (ne pas modifier) :
# PORT=3000
# HOSTNAME=0.0.0.0

# ── Orchestration Docker (lues par Compose et le Makefile, jamais par l'application) ──
# Sur un VPS avec Caddy intégré : COMPOSE_PROFILES=proxy, puis SITE_DOMAIN et ACME_EMAIL.
# Sous Coolify : ne rien définir ci-dessous.
# COMPOSE_PROFILES=proxy
# SITE_DOMAIN=example.com
# ACME_EMAIL=contact@example.com
# Proxy existant sur l'hôte : COMPOSE_FILE=compose.yaml:compose.hostport.yaml, puis WEB_PORT.
# COMPOSE_FILE=compose.yaml:compose.hostport.yaml
# WEB_PORT=3000

13.6 Cheminement des variables : Dockerfile, Compose, Coolify #

Variable Dockerfile (étape builder) Dockerfile (étape runner) Compose Coolify
APP_ENV, SITE_URL, ANALYTICS_PROVIDER, UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID, APP_VERSION ARG → ENV (visibles par next build) ARG redéclaré → ENV (le serveur valide les mêmes valeurs) build.args alimentés par ${VAR} lues dans .env Variable cochée « Available at Buildtime » (et Runtime)
BREVO_API_KEY, LOG_SALT absentes absentes liste environment: explicite (valeurs lues dans .env par interpolation) Variable « Runtime » uniquement (décocher Buildtime)
Autres variables d'exécution (EMAIL_*, CONTACT_TO_EMAIL, SEND_ACKNOWLEDGEMENT, TRUST_PROXY, LOG_LEVEL) absentes absentes liste environment: explicite Variable « Runtime »
PORT, HOSTNAME, NODE_ENV — ENV fixes environment : HOSTNAME=0.0.0.0 Ne pas définir

Compose : aucune variable de build n'est redéclarée au runtime. Le service web n'utilise pas env_file : sa liste environment: est explicite et ne contient que les variables d'exécution et les variables fixes de l'image. Les variables de build ne s'y trouvent pas : le conteneur ne reçoit leur valeur que de l'ENV de l'image, donc modifier SITE_URL dans .env sans reconstruire n'a aucun effet sur le serveur en cours d'exécution.

# compose.yaml, service `web` (extrait normatif ; §14.4 le reprend à l'identique)
environment:
  NODE_ENV: production
  PORT: '3000'
  HOSTNAME: 0.0.0.0
  BREVO_API_KEY: ${BREVO_API_KEY:-}
  EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
  EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Malka Debleds}
  CONTACT_TO_EMAIL: ${CONTACT_TO_EMAIL:-astheoris.pro@gmail.com}
  SEND_ACKNOWLEDGEMENT: ${SEND_ACKNOWLEDGEMENT:-true}
  EMAIL_DRY_RUN: ${EMAIL_DRY_RUN:-}    # vide = valeur par défaut de l'application (§13.2) ; jamais « false » forcé
  TRUST_PROXY: ${TRUST_PROXY:-true}
  LOG_LEVEL: ${LOG_LEVEL:-info}
  LOG_SALT: ${LOG_SALT:-}

Sous Coolify, le service web ne déclare aucun réseau imposé : activer l'option « Connect to Predefined Network » de la ressource pour que le proxy de Coolify atteigne le service (§14.14).

Extrait normatif du Dockerfile (le Dockerfile complet est en §14.2). Un ARG déclaré avant un FROM n'est pas visible dans l'étape : chaque étape qui en a besoin le redéclare.

# ── étape builder ──
ARG SITE_URL
ARG APP_ENV=production
ARG ANALYTICS_PROVIDER=none
ARG UMAMI_SCRIPT_URL=
ARG UMAMI_WEBSITE_ID=
ARG APP_VERSION=local
ENV SITE_URL=${SITE_URL} \
    APP_ENV=${APP_ENV} \
    ANALYTICS_PROVIDER=${ANALYTICS_PROVIDER} \
    UMAMI_SCRIPT_URL=${UMAMI_SCRIPT_URL} \
    UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} \
    APP_VERSION=${APP_VERSION}
RUN pnpm build

# ── étape runner ── (mêmes ARG redéclarés)
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 \
    SITE_URL=${SITE_URL} APP_ENV=${APP_ENV} ANALYTICS_PROVIDER=${ANALYTICS_PROVIDER} \
    UMAMI_SCRIPT_URL=${UMAMI_SCRIPT_URL} UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} APP_VERSION=${APP_VERSION}
# Aucune ligne ARG/ENV pour BREVO_API_KEY ni LOG_SALT.

Conséquences d'un changement :

Si je modifie… Il faut…
APP_ENV, SITE_URL, ANALYTICS_PROVIDER, UMAMI_*, APP_VERSION Reconstruire l'image (make deploy, ou « Redeploy » avec reconstruction dans Coolify)
Toute variable d'exécution (BREVO_API_KEY, EMAIL_*, CONTACT_TO_EMAIL, SEND_ACKNOWLEDGEMENT, TRUST_PROXY, LOG_LEVEL, LOG_SALT) Recréer le conteneur : docker compose up -d --force-recreate web
Un fichier de src/content/fr/ Reconstruire (le contenu est figé au build, §9.3.8)
Une variable de build modifiée dans .env seul (sans reconstruction) Rien ne change pour le conteneur existant ; le changement ne prend effet qu'à la reconstruction suivante

Contrôles : docker history <image> et docker inspect <image> ne montrent ni BREVO_API_KEY ni LOG_SALT ; make check-env (§14.7) vérifie la présence, les droits (600) et la cohérence de .env avant tout déploiement.

13.7 Fichiers de configuration et de contenu édités par Malka #

Fichier Ce qu'on y modifie Qui Effet Validation Détail
src/content/fr/site.ts Coordonnées, chiffres de confiance, accroche de marque Malka Reconstruction Zod, cohérence téléphone/WhatsApp (§9.3.3) §9.3
src/content/fr/offers.ts Titres, descriptions, prix, messages WhatsApp, ordre, mise en avant Malka Reconstruction Zod, X-01…X-03, X-12 §9.3, §6.5
src/content/fr/testimonials.ts Citations, consentement, publication, note de vérification Malka Reconstruction ; section affichée seulement si ≥ 1 témoignage publiable Zod (G-03), X-05, X-06, X-10 §9.3.5, §10.7.3
src/content/fr/legal.ts Identité de l'éditeur, hébergeur, TVA, durée de conservation des messages (privacy.retention), médiateur, date de mise à jour Malka Reconstruction ; bloquant en production G-01 §9.3.7, §10.7
src/content/fr/about.ts Biographie (paragraphes fournis par Malka), posture, approche humaine Malka Reconstruction Zod, G-05 §6.7
src/content/fr/orientation.ts Situations, pistes de réflexion, offre suggérée Malka Reconstruction Zod, X-04 §6.6
src/content/fr/hero.ts, philosophy.ts, method.ts, contact.ts, footer.ts, navigation.ts, seo.ts Textes des sections, libellés, métadonnées Malka (textes) Reconstruction Zod, qualité §9.3.6 §7
content-validation.json Dates et noms de validation par Malka des textes, offres, chiffres, textes alternatifs, image de partage Malka seule Reconstruction ; bloquant en production G-05 §1.3.3
public/images/…, public/og/…, docs/assets-licences.json Fichiers maîtres des visuels (un fichier par emplacement), registre des licences Malka (visuels) / exécutant (dépôt et registre) Reconstruction scripts/check-assets.ts §12.10, §12.11
.env (serveur, hors dépôt) Secrets et réglages d'exécution Exploitant Recréation du conteneur parseEnv au démarrage ; make check-env §13.2

Règles : Malka ne modifie jamais src/lib/**, src/components/** ni src/app/** ; chaque fichier de contenu commence par un commentaire d'en-tête en français (rôle du fichier, champs modifiables, ce qu'il ne faut jamais changer, §12.2) ; une erreur de saisie fait échouer le build avec le message de §9.3.7 et ne modifie pas le site en ligne.

13.8 Critères d'acceptation #

  • AC-13-01 — Le tableau de §13.2 couvre exactement les 17 variables de configuration lues par src/lib/env.ts (plus NODE_ENV, posée par l'image) ; grep -rn "process.env" src/ ne trouve des occurrences que dans lib/env.ts et instrumentation.ts ; en dehors de src/, seuls next.config.ts, scripts/**, tests/** et les fichiers *.config.* lisent process.env.
  • AC-13-02 — grep -rn "NEXT_PUBLIC_" src/ ne renvoie aucune occurrence.
  • AC-13-03 — Environnement minimal de développement (parseEnv({})) : succès, avec EMAIL_FROM_NAME = "Malka Debleds", CONTACT_TO_EMAIL = "astheoris.pro@gmail.com", SEND_ACKNOWLEDGEMENT = true, TRUST_PROXY = true, LOG_LEVEL = "info", PORT = 3000, HOSTNAME = "0.0.0.0", EMAIL_DRY_RUN = true.
  • AC-13-04 — EMAIL_DRY_RUN absent ou vide : true en development (tests compris) et en staging, false en production ; EMAIL_DRY_RUN=true avec APP_ENV=production échoue au démarrage avec un message nommant la variable.
  • AC-13-05 — APP_ENV=production sans BREVO_API_KEY, sans LOG_SALT ou sans EMAIL_FROM_ADDRESS : échec ; un seul message liste toutes les variables fautives ; aucun message ne contient la valeur d'une variable (test avec une clé « canari » invalide par ailleurs).
  • AC-13-06 — Booléens : true et false acceptés ; yes et 1 rejetés avec le nom de la variable ; la chaîne vide équivaut à la variable absente (valeur par défaut appliquée).
  • AC-13-07 — SITE_URL : example, ftp://x.test, https://user:pw@site.fr, https://site.fr/chemin rejetés ; barre finale retirée hors production, rejetée en production ; http://localhost:3000 refusé en production ; https://example.com refusé en production ; adresse IP refusée en production.
  • AC-13-08 — ANALYTICS_PROVIDER=ga4 échoue ; umami sans UMAMI_SCRIPT_URL https ou sans UMAMI_WEBSITE_ID UUID échoue ; none ignore ces deux variables.
  • AC-13-09 — EMAIL_FROM_ADDRESS en @gmail.com (et chacun des domaines de la liste du §13.4) échoue en production ; accepté en développement.
  • AC-13-10 — PORT non numérique ou hors 1–65535, CONTACT_TO_EMAIL ou EMAIL_FROM_ADDRESS invalides, APP_ENV hors énumération : échec.
  • AC-13-11 — Pendant next build sans secret d'exécution, getBuildEnv() réussit ; loadEnvOrExit() n'est pas appelé (NEXT_PHASE=phase-production-build).
  • AC-13-12 — Au démarrage de l'image de production avec une configuration invalide : app.env_invalid est journalisé (noms seulement), le message de §13.4 est écrit sur la sortie d'erreur, le processus quitte avec le code 1 et le conteneur n'est jamais healthy.
  • AC-13-13 — .env.example contient chaque variable du §13.2 (active ou commentée) ainsi que COMPOSE_PROFILES, COMPOSE_FILE, SITE_DOMAIN, ACME_EMAIL et WEB_PORT du §13.3, aucune valeur pour BREVO_API_KEY et LOG_SALT, aucun guillemet, et le domaine example.com ; un test confronte la liste de ses clés à ces tableaux.
  • AC-13-14 — L'image finale ne contient ni BREVO_API_KEY ni LOG_SALT dans docker history ; l'image construite sans --build-arg SITE_URL échoue avec un message clair.
  • AC-13-15 — Les 6 variables de build produisent un site cohérent : changer SITE_URL sans reconstruire n'a aucun effet sur le HTML et le build suivant met à jour l'origine acceptée par /api/contact, la CSP et le sitemap.
  • AC-13-16 — Le service web de compose.yaml ne déclare pas env_file ; docker compose config n'y fait figurer que la liste environment: de §13.6 (aucune variable de build) ; avec SITE_URL modifié dans .env puis docker compose up -d sans reconstruction, la variable SITE_URL du conteneur reste celle de l'image.
  • AC-13-17 — Toute variable lue par Compose, le Makefile, la CI, le Dockerfile ou un script (grep des motifs ${…}, $(…), process.env.… hors src/) figure dans §13.2 ou §13.3.
  • AC-13-18 — src/lib/content/guards.ts ne lit pas process.env et n'importe pas src/lib/env.ts (il reçoit APP_ENV en argument) ; scripts/check-assets.ts n'importe pas src/lib/env.ts ; l'image runner ne lit aucun fichier de garde au démarrage ni à la première requête (§9.3.7).

14. Architecture Docker et déploiement #

Cette section définit l'image, l'orchestration Compose, le proxy HTTPS, le runbook VPS, la variante Coolify, la CI et l'exploitation. Les versions (Node.js, pnpm, Caddy, Playwright, actions de CI…) relèvent de §9.1 ; les lignes majeures présentes dans le code ci-dessous (node:24-alpine, caddy:2-alpine, majeurs d'actions) sont celles de §9.1 et se mettent à jour selon sa politique. Les variables d'environnement applicatives et les variables d'outillage sont listées en §13 (§13.2 et §13.3) ; §14.8 n'en donne que les règles d'usage pour Compose, Caddy et le Makefile. Les décisions de cette section portent les identifiants DK-1 à DK-10.

14.1 Décisions d'architecture #

# Décision Justification
DK-1 Une seule image applicative de production (malka-site:<APP_VERSION>, étiquette = SHA Git court), sortie standalone de Next.js, utilisateur non-root nextjs (uid 1001). Les images de CI et de test vivent dans un autre dépôt d'images, malka-site-ci. Image minimale, surface d'attaque réduite ; aucune image de test ne peut être déployée ou restaurée par erreur.
DK-2 Étapes du Dockerfile : base → deps → sources → builder → runtime → runner ; étapes annexes test, dev, e2e, builder-fixtures et runner-fixtures (E2E uniquement). Cache de couches maximal ; tests reproductibles hors machine du développeur.
DK-3 Variables de build (§13) passées par ARG, figées dans l'image ; secrets uniquement au runtime. Aucun secret dans les couches de l'image.
DK-4 L'application est l'unique propriétaire des en-têtes de sécurité (CSP, HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, etc., définis en §10 et émis par headers() de next.config.ts à partir du constructeur pur src/lib/security/headers.ts). Caddy n'ajoute aucun en-tête de réponse : il ne fait que TLS, redirections, compression et reverse proxy ; le cache du HTML est celui par défaut de Next.js. Un seul endroit à auditer ; comportement identique avec Caddy, Coolify ou un proxy existant.
DK-5 Proxy HTTPS par défaut : Caddy (profil Compose proxy) avec certificats automatiques. Alternatives supportées : Coolify (§14.14.1), proxy existant sur l'hôte (§14.14.2). Exigence : « Caddy ou reverse proxy existant » + compatibilité Coolify.
DK-6 Aucun état persistant applicatif. Seul volume : données Caddy (certificats). Pas de base de données ni de stockage de messages (hors périmètre).
DK-7 Déploiement continu manuel sur le VPS en MVP (make deploy). La CI valide mais ne déploie pas. Simplicité, aucune clé SSH ni secret de prod dans la CI.
DK-8 Journaux : JSON lines sur stdout, rotation Docker json-file 10 Mo × 5. Pas de journal d'accès Caddy. Minimisation des données personnelles (adresses IP) ; §10.
DK-9 Supervision : vérification HTTP externe gratuite sur /api/health, configurée par l'opérateur ou Malka (§14.17). Aucune infrastructure de monitoring à héberger.
DK-10 Mise à jour par reconstruction d'image taguée malka-site:<APP_VERSION> (SHA Git court) ; les 3 dernières images à étiquette SHA sont conservées pour le rollback. Retour arrière en quelques secondes.

14.2 Dockerfile #

# syntax=docker/dockerfile:1.7
# Image lines follow §9.1. Build context excludes secrets (.dockerignore).
ARG NODE_IMAGE=node:24-alpine

############################ base ############################
FROM ${NODE_IMAGE} AS base
RUN apk add --no-cache libc6-compat tini
ENV NEXT_TELEMETRY_DISABLED=1 \
    npm_config_store_dir=/pnpm/store
WORKDIR /app

############################ deps ############################
# Installs all dependencies (dev included: needed by build, lint, tests).
FROM base AS deps
ENV CI=true COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
    pnpm install --frozen-lockfile

############################ test ############################
# `docker build --target test .` runs unit + components + integration tests; a failure fails the build.
# Repository-structure tests (tests/unit/tooling) need files excluded from this context: CI job `quality` runs them.
FROM deps AS test
ENV APP_ENV=development SITE_URL=http://localhost:3000
COPY . .
RUN pnpm vitest run --project unit --exclude "tests/unit/tooling/**" \
 && pnpm test:components \
 && pnpm test:integration

############################ sources #########################
# Sources + validated build variables, shared by `builder` and `builder-fixtures`.
FROM deps AS sources
ARG SITE_URL
ARG APP_ENV=production
ARG ANALYTICS_PROVIDER=none
ARG UMAMI_SCRIPT_URL=
ARG UMAMI_WEBSITE_ID=
ARG APP_VERSION=local
# Fast failure only; the full validation (https in production, Umami settings) is done by src/lib/env.ts during `pnpm build`.
RUN set -eu; \
    [ -n "${SITE_URL}" ] || { echo "ERREUR: build-arg SITE_URL manquant" >&2; exit 1; }; \
    case "${APP_ENV}" in development|staging|production) ;; \
      *) echo "ERREUR: APP_ENV doit valoir development, staging ou production" >&2; exit 1 ;; esac; \
    case "${ANALYTICS_PROVIDER}" in none|umami) ;; \
      *) echo "ERREUR: ANALYTICS_PROVIDER doit valoir none ou umami" >&2; exit 1 ;; esac
COPY . .
ENV SITE_URL=${SITE_URL} \
    APP_ENV=${APP_ENV} \
    ANALYTICS_PROVIDER=${ANALYTICS_PROVIDER} \
    UMAMI_SCRIPT_URL=${UMAMI_SCRIPT_URL} \
    UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} \
    APP_VERSION=${APP_VERSION} \
    NODE_OPTIONS=--max-old-space-size=1536

############################ builder #########################
FROM sources AS builder
# `pnpm build` = check:assets (visuels, dev-fallback, licences) + next build (+ gardes de contenu §9.3)
RUN pnpm build

############################ builder-fixtures ################
# E2E ONLY (compose profile e2e): same build with fixture testimonials and a complete legal notice.
# Never used by CI release jobs nor by `make build`; refused in production.
FROM sources AS builder-fixtures
RUN [ "${APP_ENV}" != "production" ] || { echo "ERREUR: la cible runner-fixtures est interdite en production" >&2; exit 1; }
RUN cp tests/fixtures/content/testimonials.two.ts src/content/fr/testimonials.ts \
 && cp tests/fixtures/legal/legal.complete.ts src/content/fr/legal.ts
RUN pnpm build

############################ runtime #########################
# Everything except the build output: shared by `runner` and `runner-fixtures`.
FROM base AS runtime
ARG SITE_URL
ARG APP_ENV=production
ARG ANALYTICS_PROVIDER=none
ARG UMAMI_SCRIPT_URL=
ARG UMAMI_WEBSITE_ID=
ARG APP_VERSION=local
ARG VCS_URL=
ENV NODE_ENV=production \
    PORT=3000 \
    HOSTNAME=0.0.0.0 \
    SITE_URL=${SITE_URL} \
    APP_ENV=${APP_ENV} \
    ANALYTICS_PROVIDER=${ANALYTICS_PROVIDER} \
    UMAMI_SCRIPT_URL=${UMAMI_SCRIPT_URL} \
    UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} \
    APP_VERSION=${APP_VERSION}
RUN addgroup -S -g 1001 nodejs \
 && adduser -S -u 1001 -G nodejs -h /app -s /sbin/nologin nextjs \
 && mkdir -p .next/cache && chown nextjs:nodejs .next/cache
USER nextjs
EXPOSE 3000
LABEL org.opencontainers.image.title="Malka Debleds — Entreprise vivante" \
      org.opencontainers.image.description="Landing page Next.js (sortie standalone)" \
      org.opencontainers.image.source="${VCS_URL}" \
      org.opencontainers.image.revision="${APP_VERSION}" \
      org.opencontainers.image.version="${APP_VERSION}"
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=20s \
  CMD node -e "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

############################ runner ##########################
FROM runtime AS runner
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

############################ runner-fixtures #################
# E2E ONLY: referenced by compose.yaml profile `e2e` and by no release workflow.
FROM runtime AS runner-fixtures
COPY --from=builder-fixtures --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder-fixtures --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder-fixtures --chown=nextjs:nodejs /app/public ./public

############################ dev #############################
# Used by compose.dev.yaml. Runs as the unprivileged `node` user (uid 1000).
FROM base AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 NODE_ENV=development
RUN corepack enable \
 && mkdir -p /pnpm/store /app/node_modules /app/.next \
 && chown -R node:node /pnpm /app
USER node
COPY --chown=node:node package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
RUN --mount=type=cache,id=pnpm-store-dev,target=/pnpm/store,uid=1000,gid=1000 \
    pnpm install --frozen-lockfile
EXPOSE 3000
CMD ["pnpm", "dev", "-H", "0.0.0.0"]

############################ e2e #############################
# Playwright runner. Browsers are installed from the lockfile's Playwright version (always in sync).
FROM node:24-bookworm-slim AS e2e
ARG PLAYWRIGHT_BROWSERS=chromium
ENV CI=true COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
    PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
    npm_config_store_dir=/pnpm/store
WORKDIR /work
RUN corepack enable
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
RUN --mount=type=cache,id=pnpm-store-e2e,target=/pnpm/store \
    pnpm install --frozen-lockfile
RUN pnpm exec playwright install --with-deps ${PLAYWRIGHT_BROWSERS} \
 && chmod -R a+rX /ms-playwright
COPY . .
CMD ["pnpm", "test:e2e"]

Points d'attention :

  1. HOSTNAME : Docker positionne HOSTNAME au nom du conteneur ; l'image le fixe à 0.0.0.0 pour que le serveur écoute sur toutes les interfaces. compose.yaml le fixe aussi explicitement (§14.4) ; ne jamais le définir dans .env.
  2. Variables de build figées : SITE_URL, APP_ENV, ANALYTICS_PROVIDER, UMAMI_* et APP_VERSION sont aussi des ENV du runner afin que la vérification d'origine et le health check lisent la même valeur que la pagination statique. compose.yaml ne les redéfinit pas à l'exécution (§14.4) : elles ne peuvent donc pas dériver de l'image. Les modifier exige une reconstruction (make deploy), jamais un simple redémarrage.
  3. Aucun secret dans l'image : BREVO_API_KEY et LOG_SALT ne sont ni ARG ni ENV du Dockerfile ; .env et tout fichier .env* (hors .env.example) sont exclus du contexte (§14.3).
  4. Dossiers inscriptibles : l'application n'écrit que dans /tmp et /app/.next/cache, montés en tmpfs par Compose (§14.4).
  5. Garde-fous de production au build uniquement : si APP_ENV=production et qu'un visuel dev-fallback, un champ légal vide, un registre de licences ou de validation incomplet subsiste, RUN pnpm build échoue : le Dockerfile ne contient volontairement aucun contournement. Ces gardes lisent le système de fichiers (docs/, content-validation.json, public/images) ; elles ne s'exécutent que pendant le build (NEXT_PHASE=phase-production-build ou scripts/check-assets.ts) et jamais au démarrage du conteneur : l'image runner ne contient ni ces fichiers ni scripts/, et un POST /api/contact valide y répond 200.
  6. Mémoire de build : NODE_OPTIONS=--max-old-space-size=1536 ; le VPS de build dispose d'au moins 2 Go de RAM ou d'un swap de 2 Go (§14.9).
  7. sharp et next/image : l'optimiseur d'images de Next.js (AVIF/WebP) s'exécute à l'exécution pour tous les visuels raster (§12.10) ; la variante musl de sharp est résolue par l'installation sur node:24-alpine et incluse dans la sortie standalone. L'encodage AVIF de maîtres de 3840 px est gourmand : le conteneur web dispose de 768 Mo (§14.4), images.localPatterns est limité à /images/**, et scripts/warm-cache.sh préchauffe le cache après chaque déploiement (§14.7, make warm).
  8. Cibles de test : builder-fixtures et runner-fixtures n'existent que pour le service web-e2e-fixtures du profil Compose e2e (§14.4) ; make build, make deploy et les jobs de livraison de la CI ne les construisent jamais, et le build échoue si APP_ENV=production.

14.3 .dockerignore #

.git
.github
.gitignore
.gitattributes
.editorconfig
node_modules
.next
out
coverage
playwright-report
test-results
.lighthouseci
*.tsbuildinfo
next-env.d.ts

# Secrets and local configuration (any depth)
.env
.env.*
**/.env
**/.env.*
!.env.example
*.pem
*.key

# Not needed to build or test
Dockerfile
compose*.yaml
Caddyfile
Makefile
.vscode
.idea
.DS_Store

docs/ n'est volontairement pas exclu : check-assets.ts y lit le registre des licences (§12.11.4) ; content-validation.json et scripts/ non plus (ils servent à pnpm build). tests/ et README.md ne sont pas exclus : requis par les étapes test, builder-fixtures et e2e. Les tests de structure du dépôt (tests/unit/tooling, §12.15) vérifient des fichiers que ce contexte exclut (Dockerfile, compose*.yaml, Makefile, Caddyfile, .github/, .gitignore, .editorconfig) : l'étape test les exclut et ils s'exécutent nativement dans le job quality (§14.15.1). L'étape runner ne copie que des artefacts de build (.next/standalone, .next/static, public/) : aucun fichier du contexte, ignoré ou non, n'atteint l'image finale.

14.4 compose.yaml #

name: malka-site

x-logging: &default-logging
  driver: json-file
  options:
    max-size: 10m
    max-file: '5'
    compress: 'true'

x-hardening: &hardening
  restart: unless-stopped
  read_only: true
  cap_drop: [ALL]
  security_opt:
    - no-new-privileges:true
  pids_limit: 256
  logging: *default-logging
  # No `networks:` key on purpose: every service joins the project's default network, so the
  # reverse proxy of Coolify can attach to `web` without an override (see 14.14.1).

services:
  # ---------------------------------------------------------------- application
  web:
    <<: *hardening
    image: malka-site:${APP_VERSION:-local}
    build:
      context: .
      target: runner
      args:
        SITE_URL: ${SITE_URL:-}
        APP_ENV: ${APP_ENV:-production}
        ANALYTICS_PROVIDER: ${ANALYTICS_PROVIDER:-none}
        UMAMI_SCRIPT_URL: ${UMAMI_SCRIPT_URL:-}
        UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
        APP_VERSION: ${APP_VERSION:-${SOURCE_COMMIT:-local}}
        VCS_URL: ${VCS_URL:-}
    # No `env_file`: `.env` only feeds the `${…}` interpolation below. The build variables (SITE_URL, APP_ENV,
    # ANALYTICS_PROVIDER, UMAMI_*, APP_VERSION) are baked into the image and are deliberately NOT listed here,
    # so a runtime restart can never make them drift from the build.
    environment:
      # Fixed by the image contract (never read from .env):
      NODE_ENV: production
      PORT: '3000'
      HOSTNAME: 0.0.0.0
      # Runtime variables (§13). An empty value is treated as absent: defaults come from the application.
      BREVO_API_KEY: ${BREVO_API_KEY:-}
      EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
      EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Malka Debleds}
      CONTACT_TO_EMAIL: ${CONTACT_TO_EMAIL:-astheoris.pro@gmail.com}
      SEND_ACKNOWLEDGEMENT: ${SEND_ACKNOWLEDGEMENT:-true}
      EMAIL_DRY_RUN: ${EMAIL_DRY_RUN:-}      # empty = application default: true in development/test/staging, false in production
      TRUST_PROXY: ${TRUST_PROXY:-true}
      LOG_LEVEL: ${LOG_LEVEL:-info}
      LOG_SALT: ${LOG_SALT:-}
    expose:
      - '3000'
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=64m
      - /app/.next/cache:rw,noexec,nosuid,nodev,size=128m,uid=1001,gid=1001,mode=1777
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    stop_grace_period: 20s
    mem_limit: 768m
    cpus: 1.0

  # ---------------------------------------------------------------- HTTPS proxy
  caddy:
    <<: *hardening
    profiles: [proxy]
    image: caddy:2-alpine
    depends_on:
      web:
        condition: service_healthy
    cap_add: [NET_BIND_SERVICE]
    ports:
      - '80:80'
      - '443:443'
      - '443:443/udp'
    environment:
      SITE_DOMAIN: ${SITE_DOMAIN:-}
      ACME_EMAIL: ${ACME_EMAIL:-}
      ACME_CA: ${ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=16m
    healthcheck:
      test: ['CMD', 'wget', '-q', '--spider', 'http://127.0.0.1:2019/config/']
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    mem_limit: 128m
    cpus: 0.5

  # ---------------------------------------------------------------- E2E (profile e2e)
  # Two ephemeral servers built from the same Dockerfile: `web-e2e` (default content) and
  # `web-e2e-fixtures` (published testimonials + complete legal notice). Images live in the `malka-site-ci` repository.
  web-e2e:
    <<: *hardening
    restart: 'no'
    profiles: [e2e]
    image: malka-site-ci:e2e
    build:
      context: .
      target: runner
      args:
        SITE_URL: http://web-e2e:3000          # must equal the URL the browser uses (Origin check, §9.4)
        APP_ENV: staging
        ANALYTICS_PROVIDER: none
        APP_VERSION: e2e
    environment: &e2e-environment
      NODE_ENV: production
      PORT: '3000'
      HOSTNAME: 0.0.0.0
      # Non-secret placeholders: EMAIL_DRY_RUN guarantees no network call.
      BREVO_API_KEY: e2e-dry-run-placeholder
      EMAIL_FROM_ADDRESS: e2e@example.test
      EMAIL_FROM_NAME: Malka Debleds
      CONTACT_TO_EMAIL: e2e@example.test
      SEND_ACKNOWLEDGEMENT: 'true'
      EMAIL_DRY_RUN: 'true'
      TRUST_PROXY: 'true'
      LOG_LEVEL: info
      LOG_SALT: e2e-not-a-secret-0123456789abcdef0123
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=64m
      - /app/.next/cache:rw,noexec,nosuid,nodev,size=128m,uid=1001,gid=1001,mode=1777
    healthcheck: &e2e-healthcheck
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      interval: 5s
      timeout: 5s
      retries: 12
      start_period: 5s

  web-e2e-fixtures:
    <<: *hardening
    restart: 'no'
    profiles: [e2e]
    image: malka-site-ci:e2e-fixtures
    build:
      context: .
      target: runner-fixtures
      args:
        SITE_URL: http://web-e2e-fixtures:3000
        APP_ENV: staging
        ANALYTICS_PROVIDER: none
        APP_VERSION: e2e-fixtures
    environment: *e2e-environment
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=64m
      - /app/.next/cache:rw,noexec,nosuid,nodev,size=128m,uid=1001,gid=1001,mode=1777
    healthcheck: *e2e-healthcheck

  e2e:
    profiles: [e2e]
    image: malka-site-ci:e2e-runner
    build:
      context: .
      target: e2e
      args:
        PLAYWRIGHT_BROWSERS: ${PLAYWRIGHT_BROWSERS:-chromium}
    depends_on:
      web-e2e:
        condition: service_healthy
      web-e2e-fixtures:
        condition: service_healthy
    user: '${HOST_UID:-1000}:${HOST_GID:-1000}'
    environment:
      HOME: /tmp
      CI: 'true'
      PLAYWRIGHT_BASE_URL: http://web-e2e:3000
      PLAYWRIGHT_FIXTURES_URL: http://web-e2e-fixtures:3000
      PW_ALL_BROWSERS: ${PW_ALL_BROWSERS:-0}
    volumes:
      - ./playwright-report:/work/playwright-report
      - ./test-results:/work/test-results
    logging: *default-logging

volumes:
  caddy_data:
  caddy_config:

Décisions et garde-fous :

Sujet Décision
Image malka-site:${APP_VERSION} ; APP_VERSION = SHA Git court injecté par le Makefile ; défaut local. Les images de test et de CI portent le nom de dépôt malka-site-ci : elles ne peuvent être ni restaurées par make rollback ni conservées par make prune.
Ports web n'expose que 3000 sur le réseau Compose (aucune publication d'hôte). Seul caddy publie 80/443 (+ UDP 443 pour HTTP/3). La publication de web sur 127.0.0.1 est une surcharge optionnelle (§14.5).
Réseaux Aucun réseau nommé n'est imposé : tous les services rejoignent le réseau par défaut du projet, ce qui laisse le proxy de Coolify s'y raccorder (réglage « Connect to Predefined Network », §14.14.1).
Fichier .env Lu uniquement comme source d'interpolation (${…}), jamais transmis au conteneur (env_file est absent : le fichier n'existe pas sous Coolify). La liste environment de web est le contrat d'exécution complet et ne contient aucune variable de build : SITE_URL, APP_ENV, ANALYTICS_PROVIDER, UMAMI_* et APP_VERSION restent celles de l'image.
Valeur vide Une variable absente est transmise vide ; l'application la traite comme absente (validation Zod, §13) et, en production, quitte avec un message explicite si un secret manque : le conteneur devient unhealthy, docker compose up --wait échoue. EMAIL_DRY_RUN vide prend le défaut applicatif (true hors production) ; true est interdit en production (démarrage refusé, §13).
Système de fichiers read_only: true ; écritures seulement dans les tmpfs /tmp (64 Mo) et /app/.next/cache (128 Mo, uid 1001).
Privilèges cap_drop: [ALL], no-new-privileges, utilisateur 1001 dans l'image, pids_limit. Caddy ne récupère que NET_BIND_SERVICE.
Ressources web : 768 Mo (l'optimiseur d'images de Next.js encode en AVIF à l'exécution, §12.10.3), 1 CPU. caddy : 128 Mo, 0,5 CPU.
Journaux json-file, 10 Mo × 5 fichiers compressés = 50 Mo maximum par service.
Redémarrage restart: unless-stopped ; Docker démarre au boot (§14.9). Les services du profil e2e ne redémarrent pas.
Santé Même commande que le HEALTHCHECK de l'image (le port interne est toujours 3000 ; PORT ne se change pas).
Dépendance caddy ne démarre qu'une fois web sain.
Profil e2e Trois services : web-e2e (cible runner, contenu livré), web-e2e-fixtures (cible runner-fixtures, témoignages publiés et mentions légales complètes) et e2e (Playwright). Les deux serveurs sont construits en APP_ENV=staging avec EMAIL_DRY_RUN=true et TRUST_PROXY=true (chaque test choisit son IP via X-Forwarded-For, §12.8.4). Le navigateur les atteint par leur nom de service (PLAYWRIGHT_BASE_URL, PLAYWRIGHT_FIXTURES_URL), qui est aussi la valeur de SITE_URL cuite dans chaque image (contrôle d'origine, §9.4). Ces origines HTTP ne sont pas des contextes sécurisés : le site n'utilise aucune API réservée aux contextes sécurisés. Aucun port publié, aucun webServer Playwright.
Profil proxy Activé sur le VPS par COMPOSE_PROFILES=proxy dans .env (§14.8). Jamais activé sous Coolify.

14.5 compose.dev.yaml et compose.hostport.yaml #

14.5.1 compose.dev.yaml (développement local, avec rechargement à chaud) #

name: malka-site-dev

services:
  dev:
    build:
      context: .
      target: dev
    image: malka-site:dev
    init: true
    command: ['sh', '-c', 'pnpm install --frozen-lockfile && exec pnpm dev -H 0.0.0.0']
    ports:
      - '127.0.0.1:3000:3000'
    environment:
      APP_ENV: development
      SITE_URL: http://localhost:3000
      ANALYTICS_PROVIDER: none
      APP_VERSION: dev
      # Dry-run: no email is sent; values below are non-secret placeholders for local use only.
      EMAIL_DRY_RUN: 'true'
      BREVO_API_KEY: dev-dry-run-placeholder
      EMAIL_FROM_ADDRESS: dev@example.test
      CONTACT_TO_EMAIL: dev@example.test
      TRUST_PROXY: 'false'
      LOG_LEVEL: debug
      LOG_SALT: dev-only-salt-not-a-secret-0123456789
      NEXT_TELEMETRY_DISABLED: '1'
      WATCHPACK_POLLING: ${WATCHPACK_POLLING:-false}
    volumes:
      - .:/app
      - dev_node_modules:/app/node_modules
      - dev_next:/app/.next
      - dev_pnpm_store:/pnpm/store

volumes:
  dev_node_modules:
  dev_next:
  dev_pnpm_store:

Règles :

  • Le conteneur tourne sous l'utilisateur node (uid 1000) : sur Linux, l'UID de l'hôte doit valoir 1000 pour que les fichiers créés dans le dépôt lui appartiennent ; sinon lancer pnpm dev directement sur l'hôte. Les volumes nommés node_modules, .next et le store pnpm évitent les fichiers appartenant à root dans le dépôt.
  • Sur macOS/Windows (système de fichiers partagé lent), définir WATCHPACK_POLLING=true dans le shell avant make dev.
  • Les commandes make lint et make typecheck exécutent le même service avec une commande différente (§14.7).
  • make dev ne lit pas .env : la configuration de développement est entièrement dans ce fichier.
  • Ouvrir le site sur http://localhost:3000, pas sur 127.0.0.1 ni sur l'adresse IP de la machine : le contrôle d'origine de l'API compare l'en-tête Origin à la valeur SITE_URL du service (§9.4) et refuse (FORBIDDEN_ORIGIN) toute autre origine ; en développement, la ligne de journal indique le seul nom d'hôte reçu.

14.5.2 compose.hostport.yaml (surcharge optionnelle) #

services:
  web:
    ports:
      - '127.0.0.1:${WEB_PORT:-3000}:3000'

Utilisation (proxy existant sur l'hôte, §14.14.2) : COMPOSE_FILE=compose.yaml:compose.hostport.yaml dans .env. Le port n'est publié que sur la boucle locale, jamais sur 0.0.0.0. Cette surcharge n'est pas utilisée avec Coolify (qui gère ses propres réseaux).

14.6 Caddyfile #

Caddy assure uniquement : TLS automatique, redirection www → apex, redirection HTTP → HTTPS (automatique), compression zstd/gzip et reverse proxy. Il n'ajoute, ne modifie ni ne supprime aucun en-tête de réponse (voir DK-4).

{
	email {$ACME_EMAIL}
	acme_ca {$ACME_CA}
	servers {
		protocols h1 h2 h3
		timeouts {
			read_header 10s
			read_body 10s
		}
	}
}

www.{$SITE_DOMAIN} {
	redir https://{$SITE_DOMAIN}{uri} permanent
}

{$SITE_DOMAIN} {
	encode zstd gzip

	# The application accepts 16 KB at most; refuse anything much larger before it reaches Node.js.
	request_body {
		max_size 1MB
	}

	reverse_proxy web:3000 {
		# The app is asked for uncompressed responses so that Caddy performs the compression (zstd preferred).
		header_up Accept-Encoding identity
		# Ride out a container swap during `docker compose up -d` (a few seconds) instead of returning 502.
		lb_try_duration 10s
		lb_try_interval 500ms
		transport http {
			dial_timeout 3s
			response_header_timeout 30s
		}
	}

	handle_errors {
		respond "Le site est momentanément indisponible. Merci de réessayer dans quelques instants." 503
	}
}

Règles et justifications :

  1. Domaine : tout vient de SITE_DOMAIN (apex sans schéma) ; l'apex est le domaine de référence, www redirige en 301 en conservant chemin et requête ({uri}).
  2. Adresse IP cliente : aucun trusted_proxies n'est configuré, donc Caddy ignore tout X-Forwarded-For fourni par le visiteur et transmet l'adresse réelle de la connexion. L'application, avec TRUST_PROXY=true, lit la dernière valeur de X-Forwarded-For (§9.6) : celle que le proxy de confiance a écrite ou ajoutée, jamais une valeur fabriquée par le visiteur.
  3. Pas de journal d'accès : aucune directive log n'est déclarée ; Caddy ne journalise que ses erreurs de fonctionnement (adresses IP des visiteurs non conservées).
  4. API d'administration : active sur 127.0.0.1:2019 à l'intérieur du conteneur uniquement (non publiée) ; elle sert au health check Caddy. Un changement du Caddyfile s'applique par docker compose restart caddy.
  5. Aucun en-tête de réponse ajouté : ni header, ni header_down, ni Cache-Control, ni HSTS, ni CSP — ces en-têtes viennent exclusivement de l'application (§10, §11), y compris le cache du HTML (valeurs par défaut de Next.js). La seule directive d'en-tête est header_up Accept-Encoding identity, qui concerne la requête vers l'application.
  6. Compression : l'application n'est jamais compressée deux fois (Accept-Encoding: identity en amont) ; sous Coolify ou un proxy existant, la compression native de Next.js reste active.
  7. Émission du certificat : Caddy obtient aussi le certificat de www ; l'enregistrement DNS de www doit donc exister avant le premier démarrage (§14.10).
  8. Limites de débit d'entrée : le corps de requête est plafonné à 1 Mo et la lecture de l'en-tête et du corps à 10 s, avant toute lecture par l'application (qui applique elle-même sa limite de 16 Ko).

14.7 Makefile #

L'indentation des recettes se fait par tabulation (obligatoire pour make). Toutes les commandes docker compose utilisent le fichier compose.yaml et les profils définis par COMPOSE_PROFILES (§14.8).

# Makefile — Malka Debleds · Entreprise vivante
SHELL         := /bin/bash
.DEFAULT_GOAL := help

COMPOSE       ?= docker compose
DEV           := $(COMPOSE) -f compose.dev.yaml
DEVRUN        := $(DEV) run --rm --no-deps dev sh -c
IMAGE         := malka-site
CI_IMAGE      := malka-site-ci
KEEP_IMAGES   ?= 3
SERVICE       ?=
TAG           ?=
LHCI_BASE_URL ?=
APP_VERSION   ?= $(shell git rev-parse --short=7 HEAD 2>/dev/null || echo local)
SITE_DOMAIN   ?= $(shell grep -E '^SITE_DOMAIN=' .env 2>/dev/null | tail -n1 | cut -d= -f2-)
export APP_VERSION
export HOST_UID := $(shell id -u)
export HOST_GID := $(shell id -g)

.PHONY: help dev build up down logs ps test test-e2e lint typecheck lighthouse \
        deploy release warm rollback verify tags prune check-env

help: ## Affiche cette aide
	@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "} {printf "  %-12s %s\n", $$1, $$2}'

dev: ## Développement local avec rechargement à chaud (http://localhost:3000)
	$(DEV) up --build

build: ## Construit l'image de production malka-site:$(APP_VERSION)
	$(COMPOSE) build --pull web

up: check-env ## Démarre (ou met à jour) la pile et attend qu'elle soit saine
	$(COMPOSE) up -d --wait --wait-timeout 120 --remove-orphans

down: ## Arrête la pile (les volumes de certificats sont conservés)
	$(COMPOSE) down

logs: ## Suit les journaux (make logs SERVICE=web)
	$(COMPOSE) logs -f --tail=200 $(SERVICE)

ps: ## État des conteneurs
	$(COMPOSE) ps

test: ## Tests unitaires + composants + intégration dans un conteneur (étape `test` du Dockerfile)
	docker build --target test --progress=plain -t $(CI_IMAGE):test .

test-e2e: ## Playwright contre les images construites (profil e2e : web-e2e, web-e2e-fixtures, e2e)
	mkdir -p playwright-report test-results
	$(COMPOSE) --profile e2e build web-e2e web-e2e-fixtures e2e
	$(COMPOSE) --profile e2e run --rm e2e; status=$$?; \
	$(COMPOSE) --profile e2e rm -sfv web-e2e web-e2e-fixtures; exit $$status

lint: ## ESLint dans le conteneur de dev
	$(DEVRUN) 'pnpm install --frozen-lockfile >/dev/null && pnpm lint'

typecheck: ## tsc --noEmit dans le conteneur de dev
	$(DEVRUN) 'pnpm install --frozen-lockfile >/dev/null && pnpm typecheck'

lighthouse: ## Lighthouse CI sur http://localhost:3000 (ou LHCI_BASE_URL) ; Chrome local requis, la CI fait foi
	pnpm lighthouse $(if $(LHCI_BASE_URL),--collect.url=$(LHCI_BASE_URL)/ --collect.url=$(LHCI_BASE_URL)/mentions-legales --collect.url=$(LHCI_BASE_URL)/politique-de-confidentialite)

deploy: ## VPS : arbre propre sur main, git pull, build, up, vérification, nettoyage des anciennes images
	@git diff --quiet && git diff --cached --quiet || { echo "ERREUR: arbre Git non propre (commit ou stash requis)"; exit 1; }
	@[ "$$(git branch --show-current)" = "main" ] || { echo "ERREUR: make deploy se lance depuis la branche main"; exit 1; }
	git pull --ff-only
	$(MAKE) release

release:
	$(MAKE) check-env
	$(MAKE) build
	$(MAKE) up
	$(MAKE) verify
	$(MAKE) warm
	$(MAKE) prune

warm: ## Préchauffe le cache d'images du site public (sans effet si SITE_DOMAIN est vide)
	@if [ -n "$(SITE_DOMAIN)" ]; then scripts/warm-cache.sh "https://$(SITE_DOMAIN)"; fi

verify: ## Vérifie la santé du conteneur et de l'URL publique
	@cid=$$($(COMPOSE) ps -q web); test -n "$$cid" || { echo "ERREUR: conteneur web absent"; exit 1; }; \
	  st=$$(docker inspect --format '{{.State.Health.Status}}' $$cid); \
	  [ "$$st" = "healthy" ] || { echo "ERREUR: web n'est pas healthy ($$st)"; exit 1; }; \
	  echo "web: healthy (image $$(docker inspect --format '{{.Config.Image}}' $$cid))"
	@if [ -n "$(SITE_DOMAIN)" ]; then \
	  curl -fsS --max-time 10 "https://$(SITE_DOMAIN)/api/health" | grep -q '"status":"ok"' \
	    && echo "https://$(SITE_DOMAIN)/api/health: OK" \
	    || { echo "ERREUR: la vérification publique a échoué"; exit 1; }; \
	fi

tags: ## Liste les images de production disponibles pour un rollback
	@docker image ls $(IMAGE) --format 'table {{.Tag}}\t{{.CreatedSince}}\t{{.Size}}'

rollback: ## Retour à une image précédente : make rollback TAG=<sha>
	@test -n "$(TAG)" || { echo "Usage: make rollback TAG=<sha> (voir make tags)"; exit 1; }
	@echo "$(TAG)" | grep -Eq '^[0-9a-f]{7,40}$$' || { echo "ERREUR: TAG doit être un SHA Git (7 à 40 caractères hexadécimaux)"; exit 1; }
	@docker image inspect $(IMAGE):$(TAG) >/dev/null 2>&1 || { echo "ERREUR: image $(IMAGE):$(TAG) introuvable (make tags)"; exit 1; }
	APP_VERSION=$(TAG) $(COMPOSE) up -d --no-build --wait --wait-timeout 120 --remove-orphans
	$(MAKE) verify

prune: ## Conserve les $(KEEP_IMAGES) dernières images de production (étiquettes SHA uniquement)
	@docker image ls $(IMAGE) --format '{{.Tag}}' | grep -E '^[0-9a-f]{7,40}$$' \
	  | tail -n +$$(($(KEEP_IMAGES)+1)) | xargs -r -I{} docker rmi $(IMAGE):{} || true
	@docker image ls $(CI_IMAGE) --format '{{.Repository}}:{{.Tag}}' | xargs -r docker rmi || true
	@docker image prune -f >/dev/null

check-env: ## Vérifie .env (présence, droits, variables requises, cohérence)
	@test -f .env || { echo "ERREUR: .env absent (cp .env.example .env)"; exit 1; }
	@perm=$$(stat -c %a .env); [ "$$perm" = "600" ] || { echo "ERREUR: exécuter chmod 600 .env (droits actuels: $$perm)"; exit 1; }
	@! grep -Eq '\$$\(' .env || { echo "ERREUR: .env ne peut pas contenir de substitution de commande \$$(…) : coller la valeur générée"; exit 1; }
	@appenv=$$(grep -E '^APP_ENV=' .env | tail -n1 | cut -d= -f2-); \
	  dry=$$(grep -E '^EMAIL_DRY_RUN=' .env | tail -n1 | cut -d= -f2-); \
	  [ -n "$$dry" ] || { [ "$$appenv" = "production" ] && dry=false || dry=true; }; \
	  [ "$$appenv" != "production" ] || [ "$$dry" != "true" ] || { echo "ERREUR: EMAIL_DRY_RUN=true est interdit en production"; exit 1; }; \
	  required="SITE_URL APP_ENV EMAIL_FROM_ADDRESS LOG_SALT"; \
	  [ "$$dry" = "true" ] || required="$$required BREVO_API_KEY"; \
	  for v in $$required; do grep -Eq "^$$v=.+" .env || { echo "ERREUR: $$v manquante ou vide dans .env"; exit 1; }; done; \
	  [ "$$dry" != "true" ] || echo "ATTENTION: EMAIL_DRY_RUN=true ($$appenv), aucun e-mail ne sera envoyé"
	@if grep -Eq '^COMPOSE_PROFILES=.*proxy' .env; then \
	  for v in SITE_DOMAIN ACME_EMAIL; do grep -Eq "^$$v=.+" .env || { echo "ERREUR: $$v requise avec le profil proxy"; exit 1; }; done; \
	  url=$$(grep -E '^SITE_URL=' .env | tail -n1 | cut -d= -f2-); dom=$$(grep -E '^SITE_DOMAIN=' .env | tail -n1 | cut -d= -f2-); \
	  [ "$$url" = "https://$$dom" ] || { echo "ERREUR: SITE_URL doit valoir https://$$dom"; exit 1; }; \
	fi

Règles : les valeurs de .env ne sont jamais entourées de guillemets et ne contiennent aucune substitution $(…) ; make deploy refuse de s'exécuter hors de la branche main ou avec un arbre Git non propre ; make rollback n'accepte qu'une étiquette SHA existante parmi les images de production et ne modifie ni .env, ni Caddyfile, ni le dépôt Git ; make prune ne supprime que des étiquettes SHA du dépôt malka-site (et tout le dépôt malka-site-ci, images de test).

14.8 Variables d'infrastructure (hors application) #

Les variables lues par Compose, Caddy, le Makefile, la CI et les outils de test — COMPOSE_PROFILES, COMPOSE_FILE, SITE_DOMAIN, ACME_EMAIL, ACME_CA, WEB_PORT, VCS_URL, SOURCE_COMMIT, HOST_UID, HOST_GID, PLAYWRIGHT_BASE_URL, PLAYWRIGHT_FIXTURES_URL, PLAYWRIGHT_BROWSERS, PW_ALL_BROWSERS, LHCI_BASE_URL, KEEP_IMAGES, TAG, SERVICE, COMPOSE et PRODUCTION_SITE_URL — sont toutes définies, avec leur défaut et leur caractère obligatoire, dans le tableau d'outillage de §13.3 ; ce paragraphe n'en répète pas le catalogue et ne donne que les règles d'usage propres au déploiement :

  • COMPOSE_PROFILES=proxy active le service caddy sur un VPS ; il reste vide sous Coolify ou avec un proxy existant. e2e n'y est jamais ajouté (le Makefile active ce profil explicitement).
  • COMPOSE_FILE=compose.yaml:compose.hostport.yaml ajoute la publication locale de web (proxy existant) ; WEB_PORT fixe alors son port local.
  • SITE_DOMAIN (apex sans schéma, requis avec le profil proxy) et ACME_EMAIL (requis avec le profil proxy) nomment le site Caddy et servent à l'URL de vérification du Makefile ; SITE_URL doit valoir https://$SITE_DOMAIN.
  • ACME_CA=https://acme-staging-v02.api.letsencrypt.org/directory permet un essai à blanc (certificat non reconnu par les navigateurs) ; sans valeur, l'autorité de production est utilisée.
  • APP_VERSION est calculée par le Makefile (SHA Git court) et n'est jamais écrite dans .env ; sous Coolify, SOURCE_COMMIT (fourni par Coolify) la remplace pour le build.
  • HOST_UID et HOST_GID (id -u, id -g) donnent aux rapports Playwright le bon propriétaire ; PLAYWRIGHT_BROWSERS="chromium firefox webkit" avec PW_ALL_BROWSERS=1 active la matrice complète (§12.8.4).
  • LHCI_BASE_URL redirige make lighthouse vers un autre site que http://localhost:3000.

14.9 Prérequis du VPS #

Domaine Exigence
Système Linux 64 bits LTS maintenu (Ubuntu LTS ou Debian stable), IPv4 publique (IPv6 recommandée).
Ressources 1 vCPU, 2 Go de RAM (ou 1 Go + swap de 2 Go), 20 Go de disque. La construction de l'image est le pic de consommation.
Docker Docker Engine stable courante et plugin Compose v2 installés depuis le dépôt officiel Docker (pas le paquet distribution obsolète) ; systemctl enable --now docker ; docker compose version répond.
Utilisateur Utilisateur deploy non-root, membre du groupe docker (équivalent root : réservé à l'opérateur), connexion SSH par clé uniquement.
SSH PasswordAuthentication no, PermitRootLogin no, PubkeyAuthentication yes. fail2ban (ou équivalent) recommandé.
Pare-feu Politique entrante « deny » ; autoriser uniquement 22/tcp (ou le port SSH choisi), 80/tcp, 443/tcp, 443/udp. Les ports applicatifs (3000, 2019) ne sont jamais publiés.
Mises à jour unattended-upgrades actif pour les correctifs de sécurité ; fenêtre de redémarrage automatique du noyau définie (ex. 04:00) ; docker.service activé au démarrage.
Heure Synchronisation NTP active (systemd-timesyncd ou chrony) : requise pour TLS et la limitation de débit.
Git git installé ; clé de déploiement en lecture seule sur le dépôt.
Outils make, curl, jq, openssl.
Réseau sortant HTTPS sortant vers api.brevo.com (e-mails), Let's Encrypt (certificats), registres d'images (build). Si le compte Brevo restreint les adresses IP autorisées, y ajouter l'adresse publique du VPS (ou désactiver la restriction) : un changement de VPS impose de la mettre à jour (symptôme : EMAIL_DELIVERY_FAILED avec un statut fournisseur 401).
Identité d'hébergeur Le nom, l'adresse et le téléphone de l'hébergeur du VPS sont à reporter par Malka dans legal.ts (host) ; rien n'est supposé par le code.

14.10 DNS et domaine d'envoi des e-mails #

14.10.1 Enregistrements du site #

Type Nom Valeur Remarque
A @ (apex) IPv4 du VPS TTL 300 s au moment de la bascule, relevé à 3600 s ensuite.
AAAA @ IPv6 du VPS Uniquement si l'IPv6 est routée jusqu'au conteneur ; sinon ne pas créer.
CNAME www @ (ou A/AAAA identiques à l'apex) Obligatoire : Caddy émet un certificat pour www (§14.6).
CAA (optionnel) @ 0 issue "letsencrypt.org" et 0 issue "zerossl.com" Caddy peut utiliser les deux autorités ; en déclarer une seule peut bloquer l'émission.

14.10.2 Domaine d'envoi (Brevo) #

L'adresse EMAIL_FROM_ADDRESS (§13) appartient au domaine du site (ex. contact@<domaine>) et ce domaine est authentifié dans Brevo avant la mise en production. Procédure :

  1. Dans Brevo, ajouter le domaine dans la section des expéditeurs et domaines ; Brevo affiche les enregistrements à créer. Les valeurs affichées par Brevo font foi ; les exemples ci-dessous ne servent qu'à reconnaître la forme.
  2. Créer chez le registrar : l'enregistrement de vérification de propriété (TXT de code Brevo), les enregistrements DKIM (TXT ou CNAME selon l'interface Brevo, copiés tels quels, sans modifier le nom d'hôte ni la casse) et le SPF.
  3. SPF : une seule entrée TXT v=spf1 … par domaine. S'il en existe déjà une, y insérer le mécanisme include: fourni par Brevo (exemple : v=spf1 include:spf.brevo.com ~all) au lieu d'en créer une seconde.
  4. DMARC : TXT sur _dmarc avec v=DMARC1; p=none; rua=mailto:<adresse de rapport> pendant les 2 à 4 premières semaines d'observation, puis passage à p=quarantine si les rapports ne montrent que des envois légitimes. L'adresse de rapport est fournie par Malka.
  5. Cliquer sur « Authentifier » dans Brevo ; attendre le statut « authentifié » (propagation jusqu'à 24 h, souvent < 1 h).
  6. Vérification finale : envoyer une demande réelle depuis le formulaire, ouvrir le message dans la boîte astheoris.pro@gmail.com → « Afficher l'original » : SPF: PASS, DKIM: PASS, DMARC: PASS.

14.10.3 Ordre des opérations #

Créer les enregistrements du site → attendre la propagation (dig +short <domaine> renvoie l'IP du VPS depuis deux résolveurs différents) → démarrer la pile Caddy (jamais avant : des échecs répétés d'émission déclenchent des limites de débit de l'autorité de certification) → authentifier le domaine d'envoi Brevo.

14.11 Premier déploiement (runbook) #

Pré-conditions « prêt pour la production » (toutes obligatoires, sinon le build refuse) :

  • src/content/fr/legal.ts entièrement renseigné par Malka (§9.3), y compris privacy.retention.
  • Visuels réels en place dans public/images/ et public/og/, aucune référence dev-fallback ; registre docs/assets-licences.json complet (§12.11.4).
  • content-validation.json complété par Malka (biographie, textes proposés, textes alternatifs) ; sans cela le build de production est refusé (garde G-05).
  • Décision de publication des témoignages prise (sections masquées s'il n'y en a aucun, §6.8).
  • Compte Brevo créé, clé API générée, domaine d'envoi authentifié (§14.10.2).
  • Nom de domaine acquis et zone DNS modifiable ; VPS conforme à §14.9.

Étapes :

  1. Préparer le serveur (connecté en root une seule fois) : créer l'utilisateur deploy, installer sa clé SSH, désactiver mot de passe/root SSH, activer le pare-feu (22, 80, 443/tcp, 443/udp), installer unattended-upgrades, Docker Engine + Compose, make, git, curl, jq. Attendu : docker run --rm hello-world réussit pour deploy.
  2. DNS : créer A/AAAA et CNAME www (§14.10.1). Attendu : dig +short <domaine> = IP du VPS.
  3. Cloner : git clone <url-du-dépôt> ~/malka-site && cd ~/malka-site && git checkout main.
  4. Créer la configuration : cp .env.example .env && chmod 600 .env, puis renseigner dans .env (sans guillemets, valeurs littérales) :
    • COMPOSE_PROFILES=proxy, SITE_DOMAIN=<apex>, ACME_EMAIL=<adresse> ;
    • SITE_URL=https://<apex>, APP_ENV=production, ANALYTICS_PROVIDER=none (ou umami avec ses deux variables, §13) ;
    • BREVO_API_KEY, EMAIL_FROM_ADDRESS ;
    • LOG_SALT : générer la valeur dans le terminal avec openssl rand -hex 32, puis coller le résultat après LOG_SALT= (Compose n'évalue aucune substitution $(…) dans .env) ;
    • EMAIL_DRY_RUN=false (ou ne pas définir la variable) : true est interdit en production et empêche le démarrage.
  5. Contrôler : make check-env → aucune erreur. docker compose config --quiet → aucune erreur. (Optionnel : ACME_CA staging pour un premier essai de certificat sans consommer les limites.)
  6. Construire : make build. Attendu : fin sans erreur, docker image ls malka-site affiche le tag du SHA courant. En cas d'échec, le message du garde-fou (champ légal, dev-fallback, image non conforme, licence, registre de validation) indique l'élément à corriger — ne jamais contourner en passant APP_ENV=staging. Vérifier qu'aucun secret n'est dans l'image : docker history --no-trunc malka-site:<sha> | grep -Ei 'BREVO_API_KEY|LOG_SALT|xkeysib-' ne renvoie rien.
  7. Démarrer : make up. Attendu : web et caddy « healthy ». Le certificat est obtenu en 10 à 60 s : docker compose logs --no-log-prefix caddy | grep -i "certificate obtained".
  8. Vérifier le site (depuis un poste extérieur) :
    • curl -sI https://<apex>/ → HTTP/2 200 ;
    • curl -sI http://<apex>/ → 301/308 vers https ;
    • curl -sI https://www.<apex>/ → 301 vers https://<apex>/ ;
    • curl -s https://<apex>/api/health | jq . → {"ok":true,"data":{"status":"ok","version":"<sha>",…}} ;
    • curl -sI https://<apex>/ | grep -iE 'content-security-policy|strict-transport-security|x-content-type-options' → en-têtes présents (émis par l'application) ;
    • curl -s https://<apex>/robots.txt référence https://<apex>/sitemap.xml ; curl -sI https://<apex>/ | grep -i x-robots-tag ne renvoie rien (site indexable) ;
    • ouvrir https://<apex>/ et parcourir hero, méthode, offres, orientation ; ne pas soumettre le formulaire avant l'étape 9.
  9. Vérifier l'envoi réel : soumettre une demande réelle depuis le formulaire. La notification arrive à astheoris.pro@gmail.com avec Reply-To = e-mail saisi, l'accusé arrive au visiteur, SPF/DKIM/DMARC passent (§14.10.2). Vérifier aussi le dossier spam. Puis make verify.
  10. Test de redémarrage : sudo reboot ; après 2 minutes, make ps montre web et caddy « healthy » sans intervention.
  11. Supervision : créer la vérification externe de /api/health (§14.17).
  12. Audit final : lancer Lighthouse mobile sur l'URL publique, sans la configuration de la CI (qui désactive l'audit is-crawlable pour les builds non indexables) : Performance ≥ 90, Accessibilité 100, Bonnes pratiques ≥ 95, SEO 100 ; conserver le rapport dans docs/perf-report.md.
  13. Consigner : créer le tag vX.Y.Z sur le commit déployé et noter le SHA déployé (make tags).

Une répétition avec envoi simulé se fait sur un déploiement distinct en APP_ENV=staging (où EMAIL_DRY_RUN vaut true par défaut), jamais en production.

14.12 Procédure de mise à jour #

Toute mise à jour (code, contenu, image, variable de build) suit la même procédure, depuis ~/malka-site sur le VPS :

  1. Vérifier que le commit à déployer est sur main avec la CI verte et, pour une version, le job release-gate vert (§14.15).
  2. make tags : noter le tag actuellement en service (point de retour).
  3. Si .env doit changer (variable de build ou secret), le modifier d'abord (chmod 600 conservé).
  4. make deploy : contrôle de l'arbre Git propre et de la branche main → git pull --ff-only → check-env → docker compose build --pull web → up -d --wait → verify → warm (préchauffage du cache d'images) → prune.
  5. Contrôler le résultat : make verify affiche « healthy » et l'URL publique répond ; curl -s https://<apex>/api/health | jq -r .data.version = SHA attendu.
  6. Contrôle fonctionnel de non-régression : page d'accueil, un CTA WhatsApp, soumission d'une demande de test (à signaler comme test dans le message).

Comportement attendu pendant la mise à jour : la construction se fait avant l'arrêt de l'ancien conteneur ; la bascule (up -d) interrompt web quelques secondes ; Caddy temporise les requêtes jusqu'à 10 s (lb_try_duration) avant de répondre 503. Aucune migration de données n'existe.

Modifier Caddyfile : docker compose restart caddy puis docker compose logs --no-log-prefix caddy. Modifier uniquement .env pour une variable d'exécution (clé API, LOG_LEVEL…) : docker compose up -d suffit. Modifier une variable de build (SITE_URL, APP_ENV, ANALYTICS_*) : make deploy obligatoire — ces variables ne sont pas transmises à l'exécution (§14.4), un simple redémarrage n'a aucun effet sur elles.

14.13 Procédure de rollback #

  1. make tags : choisir l'étiquette SHA précédente (les 3 dernières images de production sont conservées par défaut).
  2. make rollback TAG=<sha> : redémarre web sur malka-site:<sha> sans reconstruire (seules les étiquettes SHA des images de production sont acceptées), attend la santé, exécute make verify.
  3. Contrôler curl -s https://<apex>/api/health | jq -r .data.version = <sha>.
  4. Ouvrir une PR de correction ou de revert sur main. Ne pas relancer make deploy avant correction : git pull repartirait sur le commit défectueux.
  5. Si l'image visée n'existe plus localement : git checkout <sha> && make build && make up, puis git switch main. Sous Coolify, le retour arrière passe par l'interface de Coolify (§14.14.1, étape 5).

Limites : le rollback ne revient ni sur .env ni sur Caddyfile (à restaurer manuellement) ; aucune donnée applicative n'est concernée. Un rollback est considéré réussi si web est « healthy » en moins de 60 s.

14.14 Variantes de déploiement #

14.14.1 Variante Coolify #

Coolify gère le proxy (TLS, redirections) ; le profil proxy n'est donc pas utilisé. Configuration :

  1. Créer une ressource de type Docker Compose (build pack « Docker Compose ») liée au dépôt, branche main, fichier /compose.yaml. Activer l'option « Connect to Predefined Network » de la ressource : compose.yaml n'impose aucun réseau à web, et c'est ce réglage qui raccorde le proxy de Coolify au service ; sans lui, le domaine répond en 502 alors que le conteneur est sain. Vérifier que la page répond via le domaine après le premier déploiement.
  2. Domaines : sur le service web, saisir https://<apex>:3000 (le port interne 3000 est celui à router). Définir la redirection www → apex dans Coolify (domaine www en redirection) ; ne pas déclarer de domaine pour caddy.
  3. Variables d'environnement (onglet « Environment Variables ») :
    • Disponibles au build : SITE_URL, APP_ENV=production, ANALYTICS_PROVIDER, UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID (cases « Build Variable »). APP_VERSION peut rester vide : le build utilise SOURCE_COMMIT fourni par Coolify (§14.4).
    • Runtime uniquement (case « Build Variable » décochée) : BREVO_API_KEY, LOG_SALT — ces deux secrets ne doivent jamais être disponibles au build.
    • Runtime : EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME, CONTACT_TO_EMAIL, SEND_ACKNOWLEDGEMENT, TRUST_PROXY=true, LOG_LEVEL. Ne pas définir EMAIL_DRY_RUN en production (ou la mettre à false).
    • Ne pas définir COMPOSE_PROFILES, SITE_DOMAIN, ACME_*, HOSTNAME, PORT.
  4. Santé : le healthcheck de compose.yaml est utilisé par Coolify ; si l'interface demande un chemin, saisir /api/health, méthode GET, port 3000, code attendu 200, intervalle 30 s.
  5. Déploiement : bouton « Deploy » (ou webhook, hors MVP). Retour arrière : sous Coolify, il passe par l'interface (redéploiement d'un déploiement précédent, ou nouveau déploiement d'un commit antérieur), pas par make rollback : l'image porte l'étiquette de build de Coolify et le Makefile n'est pas utilisé.
  6. Les profils proxy et e2e n'étant pas activés, Coolify ne démarre que web.
  7. X-Forwarded-For : le proxy de Coolify écrit l'adresse réelle du client ; l'application lit la dernière valeur de l'en-tête (§9.6), donc TRUST_PROXY=true reste correct même si le proxy ajoute l'adresse à la fin d'une liste. À vérifier une fois : 6 soumissions de test rapprochées depuis une même connexion déclenchent bien RATE_LIMITED (429) à la 6e (§9.6). Derrière un CDN (par exemple Cloudflare), tous les visiteurs partageraient l'adresse du CDN : déclarer ses plages comme proxys de confiance de l'entrée du proxy, ou régler TRUST_PROXY=false (seau commun, restrictif mais sûr).
  8. Le .env n'existe pas dans Coolify ; l'application lit les variables listées dans environment: (§14.4), alimentées par l'interface.

14.14.2 Variante « reverse proxy existant sur l'hôte » #

Cas d'un nginx/Traefik/Caddy déjà présent : COMPOSE_PROFILES reste vide, COMPOSE_FILE=compose.yaml:compose.hostport.yaml publie web sur 127.0.0.1:3000. Exigences minimales du proxy : TLS valide sur l'apex, redirection www → apex en 301, adresse du client en dernière position de X-Forwarded-For (écrasement ou ajout, jamais une valeur fabriquée placée en dernier par le visiteur), X-Forwarded-Proto: https, aucun ajout/suppression d'en-tête de sécurité. Exemple nginx (extrait du server HTTPS) :

location / {
    proxy_pass         http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-For   $remote_addr;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_read_timeout 30s;
}

Si le proxy ne peut pas garantir que l'adresse réelle du client est la dernière valeur de X-Forwarded-For, définir TRUST_PROXY=false : tous les visiteurs partagent alors le seau unknown de la limitation de débit (§9.6), ce qui est restrictif mais sûr.

14.15 Intégration continue (.github/workflows/ci.yml) #

14.15.1 Workflow #

Les actions sont épinglées sur le tag majeur stable courant à l'écriture du fichier (les majeurs ci-dessous sont indicatifs ; la politique de mise à jour est celle de §9.1). Les jobs unit-integration, e2e, lighthouse et security dépendent de quality ; visual ne s'exécute que sur main ; docker-build dépend de unit-integration, e2e et lighthouse ; release-gate ne s'exécute que sur tag v* ou lancement manuel. Les images construites par la CI portent le nom de dépôt malka-site-ci.

name: CI

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
  workflow_dispatch:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  quality:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      APP_ENV: staging
      SITE_URL: http://localhost:3000
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - name: Check PR title (Conventional Commits)
        if: github.event_name == 'pull_request'
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: |
          printf '%s' "$PR_TITLE" | grep -Eq '^(feat|fix|content|perf|refactor|style|test|docs|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .{1,72}$' \
            || { echo "::error::Titre de PR non conforme à Conventional Commits"; exit 1; }
      - run: pnpm format:check
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm check:assets
      - run: pnpm check:guards
      - name: Repository structure tests (files excluded from the Docker context)
        run: pnpm vitest run --project unit tests/unit/tooling

  unit-integration:
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - name: Unit + components + integration tests (Dockerfile target "test")
        uses: docker/build-push-action@v6
        with:
          context: .
          target: test
          push: false
          cache-from: type=gha,scope=test
          cache-to: type=gha,scope=test,mode=max

  e2e:
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 25
    env:
      PLAYWRIGHT_BROWSERS: chromium firefox webkit
      PW_ALL_BROWSERS: '1'
    steps:
      - uses: actions/checkout@v5
      - name: Prepare
        run: |
          echo "HOST_UID=$(id -u)" >> "$GITHUB_ENV"
          echo "HOST_GID=$(id -g)" >> "$GITHUB_ENV"
          mkdir -p playwright-report test-results
      - name: Build the application images and the Playwright runner
        run: docker compose --profile e2e build web-e2e web-e2e-fixtures e2e
      - name: Playwright E2E and accessibility scans against the built containers
        run: docker compose --profile e2e run --rm e2e
      - name: Server logs (on failure)
        if: failure()
        run: docker compose --profile e2e logs --no-log-prefix web-e2e web-e2e-fixtures | tail -n 200
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: |
            playwright-report
            test-results
          retention-days: 7
      - name: Cleanup
        if: always()
        run: docker compose --profile e2e down -v

  visual:
    if: github.ref == 'refs/heads/main'
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v5
      - name: Prepare
        run: |
          echo "HOST_UID=$(id -u)" >> "$GITHUB_ENV"
          echo "HOST_GID=$(id -g)" >> "$GITHUB_ENV"
          mkdir -p playwright-report test-results
      - run: docker compose --profile e2e build web-e2e web-e2e-fixtures e2e
      - name: Visual regression (Chromium, Linux)
        run: docker compose --profile e2e run --rm e2e pnpm test:visual
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-report
          path: |
            playwright-report
            test-results
          retention-days: 7
      - name: Cleanup
        if: always()
        run: docker compose --profile e2e down -v

  lighthouse:
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - name: Build image (staging build, same Dockerfile as production)
        uses: docker/build-push-action@v6
        with:
          context: .
          target: runner
          load: true
          tags: malka-site-ci:lhci
          build-args: |
            SITE_URL=http://localhost:3000
            APP_ENV=staging
            ANALYTICS_PROVIDER=none
            APP_VERSION=lhci
          cache-from: type=gha,scope=runner
          cache-to: type=gha,scope=runner,mode=max
      - name: Start the container (read-only root filesystem, as in production)
        run: |
          docker run -d --name site -p 3000:3000 --read-only \
            --memory 768m \
            --tmpfs /tmp --tmpfs /app/.next/cache:rw,uid=1001,gid=1001,mode=1777 \
            --cap-drop ALL --security-opt no-new-privileges:true \
            -e EMAIL_DRY_RUN=true -e BREVO_API_KEY=ci-dry-run-placeholder \
            -e EMAIL_FROM_ADDRESS=ci@example.test -e CONTACT_TO_EMAIL=ci@example.test \
            -e LOG_SALT=ci-not-a-secret-0123456789abcdef0123 \
            malka-site-ci:lhci
          for i in $(seq 1 30); do
            [ "$(docker inspect --format '{{.State.Health.Status}}' site)" = "healthy" ] && exit 0
            sleep 2
          done
          docker logs site
          exit 1
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lighthouse
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: lighthouse-report
          path: .lighthouseci
          retention-days: 7

  security:
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - name: Dependency audit (production dependencies, high and critical)
        run: pnpm audit --prod --audit-level=high
      - name: No Brevo API key committed
        run: '! git grep -nE "xkeysib-[A-Za-z0-9-]{20,}" -- . ":!tests"'
      - name: Build context excludes secrets
        run: grep -qxF '**/.env' .dockerignore && grep -qxF '.git' .dockerignore

  docker-build:
    needs: [unit-integration, e2e, lighthouse]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - name: Build the runner image
        uses: docker/build-push-action@v6
        with:
          context: .
          target: runner
          load: true
          tags: malka-site-ci:ci
          build-args: |
            SITE_URL=http://localhost:3000
            APP_ENV=staging
            APP_VERSION=${{ github.sha }}
          cache-from: type=gha,scope=runner
      - name: Verify image properties
        run: |
          test "$(docker image inspect malka-site-ci:ci --format '{{.Config.User}}')" = "nextjs"
          docker image inspect malka-site-ci:ci --format '{{json .Config.Healthcheck}}' | grep -q '/api/health'
          test "$(docker run --rm --entrypoint id malka-site-ci:ci -u)" = "1001"
          docker run --rm --entrypoint sh malka-site-ci:ci -c 'test ! -e /app/.env && test ! -d /app/tests && test ! -d /app/scripts && test -d /app/public/images && test -d /app/.next/static'
          ! docker history --no-trunc malka-site-ci:ci | grep -Ei 'BREVO_API_KEY|LOG_SALT|xkeysib-'
          size=$(docker image inspect malka-site-ci:ci --format '{{.Size}}')
          echo "Image size: $size bytes"
          test "$size" -le 419430400   # 400 MiB
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - name: Start the container
        run: |
          docker run -d --name site -p 3000:3000 --read-only \
            --tmpfs /tmp --tmpfs /app/.next/cache:rw,uid=1001,gid=1001,mode=1777 \
            -e EMAIL_DRY_RUN=true -e BREVO_API_KEY=ci-dry-run-placeholder \
            -e EMAIL_FROM_ADDRESS=ci@example.test -e CONTACT_TO_EMAIL=ci@example.test \
            -e LOG_SALT=ci-not-a-secret-0123456789abcdef0123 \
            malka-site-ci:ci
          for i in $(seq 1 30); do
            [ "$(docker inspect --format '{{.State.Health.Status}}' site)" = "healthy" ] && exit 0
            sleep 2
          done
          docker logs site
          exit 1
      - name: JavaScript budget of the first load
        run: pnpm check:bundle
      - name: No fixtures and no secrets in the build output
        run: |
          docker cp site:/app/.next ./.next
          pnpm check:no-fixtures
          ! grep -rIlE 'BREVO_API_KEY|LOG_SALT|api\.brevo\.com|xkeysib-' .next/static

  release-gate:
    if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
    needs: [docker-build, security]
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v5
      - name: Require the production URL variable
        env:
          PRODUCTION_SITE_URL: ${{ vars.PRODUCTION_SITE_URL }}
        run: test -n "$PRODUCTION_SITE_URL" || { echo "::error::Variable de dépôt PRODUCTION_SITE_URL manquante"; exit 1; }
      - uses: docker/setup-buildx-action@v3
      - name: Production build (fails if legal fields, assets, licences or validations are incomplete)
        uses: docker/build-push-action@v6
        with:
          context: .
          target: runner
          push: false
          build-args: |
            SITE_URL=${{ vars.PRODUCTION_SITE_URL }}
            APP_ENV=production
            ANALYTICS_PROVIDER=${{ vars.ANALYTICS_PROVIDER || 'none' }}
            UMAMI_SCRIPT_URL=${{ vars.UMAMI_SCRIPT_URL }}
            UMAMI_WEBSITE_ID=${{ vars.UMAMI_WEBSITE_ID }}
            APP_VERSION=${{ github.sha }}
          cache-from: type=gha,scope=runner

Règles du workflow :

  • Aucun secret n'est requis par la CI. Les valeurs factices des jobs e2e, lighthouse et docker-build sont publiques par construction et protégées par EMAIL_DRY_RUN=true.
  • Les builds de CI utilisent APP_ENV=staging pour que le pipeline reste vert pendant que Malka n'a pas encore fourni les éléments légaux et les visuels ; la porte de production est le job release-gate, bloquant pour toute version taguée. La cible runner-fixtures n'est construite que par le job e2e et visual (via le profil Compose e2e), jamais par docker-build ni release-gate.
  • Un build non production est non indexable (voir §11) ; c'est pourquoi l'audit is-crawlable est retiré de la configuration Lighthouse ci-dessous. L'indexabilité de la production est contrôlée à l'étape 8 du runbook (§14.11).
  • Les jobs security (audit des dépendances de production, recherche de clé API dans le dépôt, contenu de .dockerignore), e2e (parcours et scans d'accessibilité axe, cinq projets navigateur) et docker-build (budget JS, absence de fixtures et de secrets dans le build, propriétés de l'image) portent les contrôles transverses de §15.11.
  • Durée cible du pipeline complet : ≤ 20 minutes.

14.15.2 Protection de branche #

Sur main : fusion par PR uniquement, squash, branche à jour exigée, statuts requis quality, unit-integration, e2e, lighthouse, docker-build, security, au moins une approbation (zéro si Malka et l'opérateur ne forment qu'un seul mainteneur : la CI verte est alors le garde-fou), suppression des branches après fusion, pas de push forcé. Le job visual est requis sur main pour les modifications visuelles (§15.9). Une version se publie en créant le tag vX.Y.Z sur main ; le job release-gate doit être vert avant tout make deploy.

14.15.3 lighthouserc.json #

{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/mentions-legales",
        "http://localhost:3000/politique-de-confidentialite"
      ],
      "numberOfRuns": 3,
      "settings": {
        "chromeFlags": "--no-sandbox --headless=new",
        "skipAudits": ["is-crawlable"]
      }
    },
    "assert": {
      "assertMatrix": [
        {
          "matchingUrlPattern": "^https?://[^/]+/$",
          "aggregationMethod": "median-run",
          "assertions": {
            "categories:performance": ["error", { "minScore": 0.9 }],
            "categories:accessibility": ["error", { "minScore": 1 }],
            "categories:best-practices": ["error", { "minScore": 0.95 }],
            "categories:seo": ["error", { "minScore": 1 }],
            "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
            "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
            "total-blocking-time": ["error", { "maxNumericValue": 200 }],
            "resource-summary:script:size": ["error", { "maxNumericValue": 174080 }],
            "unsized-images": ["error", { "minScore": 1 }],
            "modern-image-formats": ["error", { "minScore": 1 }],
            "uses-text-compression": ["error", { "minScore": 1 }],
            "target-size": ["error", { "minScore": 1 }],
            "errors-in-console": ["error", { "minScore": 1 }],
            "uses-responsive-images": ["warn", { "minScore": 0.9 }],
            "font-display": ["warn", { "minScore": 1 }]
          }
        },
        {
          "matchingUrlPattern": "^https?://[^/]+/(mentions-legales|politique-de-confidentialite)$",
          "aggregationMethod": "median-run",
          "assertions": {
            "categories:performance": ["error", { "minScore": 0.9 }],
            "categories:accessibility": ["error", { "minScore": 1 }],
            "categories:best-practices": ["error", { "minScore": 0.95 }],
            "categories:seo": ["error", { "minScore": 1 }],
            "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }]
          }
        }
      ]
    },
    "upload": { "target": "filesystem", "outputDir": ".lighthouseci" }
  }
}

C'est l'unique configuration Lighthouse CI du dépôt (port 3000). skipAudits: ["is-crawlable"] retire l'audit de la catégorie SEO, ce qui permet d'exiger un score de 1 sur les builds non production (non indexables par conception) ; un audit manuel de production se fait sans cette option (runbook, étape 12). La valeur 174080 octets = 170 Ko, budget de JavaScript de première charge de / (§11). Aucune cible d'envoi externe n'est utilisée : les rapports ne quittent pas la machine d'exécution. Le Total Blocking Time (≤ 200 ms) sert de mesure de laboratoire pour l'INP (≤ 200 ms en terrain, §11).

14.15.4 Livraison continue : manuelle en MVP #

La CI ne se connecte jamais au VPS. Le déploiement est déclenché par l'opérateur avec make deploy (§14.12). Ce choix évite de stocker une clé SSH ou des secrets de production dans la CI. Une automatisation (webhook Coolify, action SSH) est une évolution possible, hors périmètre MVP.

14.16 Sauvegardes et reprise #

Élément Stateful ? Stratégie
Code, contenus, visuels optimisés, configuration Docker Non (dans Git) Le dépôt Git est la source de vérité. Une copie distante du dépôt suffit.
Fichiers maîtres des visuels (public/images/, public/og/) Non (dans Git) Versionnés avec le dépôt ; Malka conserve en plus ses fichiers sources d'origine dans son propre stockage.
Preuves de licences/droits Hors dépôt Conservées par Malka ; le registre docs/assets-licences.json (dans Git) en référence l'emplacement.
.env (secrets) Oui, hors Git Copie dans le gestionnaire de mots de passe de l'opérateur. BREVO_API_KEY se régénère dans Brevo ; LOG_SALT se régénère (seule conséquence : discontinuité des empreintes IP dans les journaux).
Volume caddy_data (certificats, clé de compte ACME) Oui Sauvegarde facultative : docker run --rm -v malka-site_caddy_data:/data -v "$PWD":/backup alpine tar czf /backup/caddy-data.tgz -C /data . ; en cas de perte, Caddy ré-émet automatiquement (attention aux limites de l'autorité : ne pas répéter plus de quelques fois par semaine).
Données de visiteurs — Aucune n'est stockée (pas de base, pas d'archive des messages) : rien à sauvegarder ni à restaurer (§10).

Objectif de reprise après perte totale du VPS : site de nouveau en ligne en moins d'une heure en rejouant §14.9 à §14.11 sur un nouveau serveur (RTO), sans perte de données (RPO nul). Un instantané du VPS par l'hébergeur est conseillé mais non requis.

14.17 Observabilité et exploitation #

14.17.1 Journaux #

  • Application : JSON lines sur stdout (champs ts, level, event, requestId, outcome, code, durationMs, §9.7). Aucune donnée personnelle n'y figure.
  • Consultation : make logs SERVICE=web ; filtre des erreurs sur 24 h : docker compose logs --no-log-prefix --since 24h web | jq -c 'select(.level=="error")' ; envois bloqués anti-spam : … | jq -c 'select(.outcome=="spam_dropped")'.
  • Rotation : 10 Mo × 5 fichiers par service (§14.4). Les journaux sont attachés au conteneur : ils disparaissent à sa recréation ; pour analyser un incident avant une mise à jour, exporter d'abord docker compose logs --no-log-prefix web > ~/web-$(date +%F).log (fichier à supprimer après analyse, il peut contenir des empreintes d'IP salées).

14.17.2 Santé #

  • Conteneur : docker inspect --format '{{.State.Health.Status}}' $(docker compose ps -q web) ; make verify.
  • Un conteneur unhealthy n'est pas redémarré par Docker seul : restart: unless-stopped ne s'applique qu'aux arrêts du processus ; l'alerte externe ci-dessous est le mécanisme de détection, et l'action de l'opérateur est docker compose restart web (puis make verify).

14.17.3 Supervision externe (recommandée, configurée par l'opérateur ou Malka) #

Créer, chez un service gratuit de supervision HTTP au choix (par exemple UptimeRobot, Better Stack ou une instance Uptime Kuma auto-hébergée), :

Contrôle Paramètres
Disponibilité de l'application GET https://<apex>/api/health, toutes les 5 min, délai max 10 s, succès = code 200 et présence du mot-clé "status":"ok".
Page publique GET https://<apex>/, toutes les 5 min, succès = 200 et mot-clé « Malka Debleds ».
Certificat TLS Alerte si l'expiration est dans moins de 14 jours (Caddy renouvelle ~30 jours avant ; une alerte signale un échec de renouvellement).
Échéance du nom de domaine Rappel calendrier 60 jours avant expiration (responsabilité de Malka).

Alertes : e-mail à l'opérateur et à Malka après 2 échecs consécutifs (10 min) ; e-mail de retour à la normale activé. Le point de terminaison de santé ne renvoie aucune donnée personnelle et ne fait aucun appel externe.

14.17.4 Exploitation courante #

Fréquence Action
À chaque déploiement make verify + test fonctionnel (§14.12).
Mensuelle Envoyer une demande de test via le formulaire et confirmer la réception (surveille Brevo, SPF/DKIM/DMARC) ; consulter le tableau de bord Brevo (rebonds, plaintes).
Mensuelle docker system df ; make prune ; vérifier l'espace disque (> 30 % libre) et les mises à jour de sécurité du système (unattended-upgrades).
Trimestrielle Mettre à jour les dépendances et l'image de base (pnpm up reste dans la même majeure ; make build avec --pull), exécuter la CI, déployer ; recréer web (docker compose up -d --force-recreate web) pour purger les journaux de plus de 12 mois (sauf incident en cours) ; renouveler au besoin la clé API Brevo ; vérifier le rapport DMARC et envisager p=quarantine.

14.18 Critères d'acceptation (§14) #

ID Critère Vérification
AC-14.2-01 docker build --target runner réussit avec SITE_URL=http://localhost:3000 APP_ENV=staging ; l'image finale tourne sous l'utilisateur nextjs (uid 1001), NODE_ENV=production, expose 3000. Job docker-build (§14.15).
AC-14.2-02 Le build échoue avec un message français explicite si SITE_URL est absent ou si APP_ENV est invalide (Dockerfile), et — pendant pnpm build, par la validation de src/lib/env.ts (garde G-06) — si APP_ENV=production et SITE_URL ne commence pas par https://. Trois exécutions docker build de test (script d'intégration de CI ou revue manuelle documentée).
AC-14.2-03 Le build APP_ENV=production échoue tant qu'un champ requis de legal.ts est vide, qu'une référence dev-fallback subsiste, que le registre de licences ou content-validation.json est incomplet. Job release-gate + test d'intégration §12.11.
AC-14.2-04 L'image finale ne contient ni .env, ni tests/, ni scripts/, ni sources TypeScript ; elle contient public/images et .next/static. Taille ≤ 400 Mio. Un POST /api/contact valide sur un conteneur APP_ENV=production démarré sans docs/ ni content-validation.json répond 200 (aucune garde de système de fichiers à l'exécution). Job docker-build + test d'intégration §9.3.
AC-14.2-05 docker history / docker inspect ne révèlent aucune valeur de BREVO_API_KEY ou LOG_SALT. Job docker-build (`docker history
AC-14.2-06 HEALTHCHECK : intervalle 30 s, délai 5 s, 3 essais, démarrage 20 s, requête node fetch sur /api/health ; le conteneur passe healthy en < 40 s. docker inspect + job lighthouse (attente de santé).
AC-14.2-07 docker build --target test . exécute unit + composants + intégration (hors tests/unit/tooling) et échoue si un test échoue. Job unit-integration.
AC-14.2-08 La cible runner-fixtures n'est référencée que par compose.yaml (service web-e2e-fixtures) ; son build échoue si APP_ENV=production ; aucune étape de release-gate ou de docker-build ne la construit. Revue de ci.yml + recherche textuelle.
AC-14.4-01 docker compose config --quiet réussit sans .env (cas Coolify) et avec .env. CI/étape runbook 5.
AC-14.4-02 web : ReadonlyRootfs=true, CapDrop=[ALL], SecurityOpt contient no-new-privileges, aucune publication de port d'hôte, limites mémoire (768 Mo) et CPU et rotation de journaux 10 Mo × 5 présentes, aucun env_file ni réseau nommé imposé. docker inspect sur le conteneur de production (étape runbook 7) + lecture de docker compose config.
AC-14.4-03 Le conteneur démarre et sert / avec le système de fichiers en lecture seule (écritures uniquement dans /tmp et /app/.next/cache). Job lighthouse (même options docker run).
AC-14.4-04 Le profil e2e exécute les parcours Playwright contre les images construites (web-e2e et web-e2e-fixtures), sans publier de port, sans webServer, avec EMAIL_DRY_RUN=true, et produit playwright-report/. make test-e2e, job e2e.
AC-14.4-05 Le profil proxy démarre caddy uniquement quand web est sain ; sans COMPOSE_PROFILES=proxy, docker compose up ne démarre que web. docker compose ps dans les deux configurations.
AC-14.4-06 Modifier SITE_URL (ou toute variable de build) dans .env puis exécuter docker compose up -d --force-recreate ne change ni l'origine acceptée, ni le HTML, ni la CSP : seule une reconstruction les modifie. docker inspect (variables de web) avant/après.
AC-14.4-07 20 requêtes concurrentes /_next/image?w=2560&q=75 sur un maître de 3840 px ne font pas dépasser 70 % de mem_limit au conteneur web et ne provoquent aucun arrêt par manque de mémoire. Mesure docker stats à la mise en production, consignée dans docs/perf-report.md.
AC-14.5-01 make dev sert le site sur http://localhost:3000 avec rechargement à chaud et EMAIL_DRY_RUN=true ; make lint et make typecheck s'exécutent dans le conteneur de dev. Exécution manuelle documentée.
AC-14.6-01 http://<apex> → 301/308 vers https://<apex> ; https://www.<apex>/chemin?x=1 → 301 vers https://<apex>/chemin?x=1. curl -sI (runbook étape 8).
AC-14.6-02 Les réponses sont compressées (content-encoding: zstd ou gzip selon le client) pour les ressources texte, sans double compression. curl -sI -H 'Accept-Encoding: zstd, gzip' https://<apex>/.
AC-14.6-03 Les en-têtes de sécurité présents sont exactement ceux émis par l'application (§10) : le Caddyfile ne contient ni header ni header_down (seule la directive header_up Accept-Encoding identity concerne la requête). Revue + recherche textuelle dans le Caddyfile.
AC-14.6-04 Le certificat TLS est valide et délivré pour l'apex et www ; HTTP/2 actif (HTTP/3 annoncé). curl -sI --http2, openssl s_client ou outil de test TLS.
AC-14.6-05 Aucune adresse IP de visiteur n'est écrite dans les journaux de Caddy (pas de log d'accès). Revue du Caddyfile + docker compose logs caddy.
AC-14.7-01 Les 12 cibles dev, build, up, down, logs, test, test-e2e, lint, typecheck, lighthouse, deploy, rollback existent (plus help, ps, verify, tags, prune, check-env, release, warm) ; make help les documente. Il n'existe pas de cible images. make -n <cible> et make help.
AC-14.7-02 make check-env échoue avec un message précis si .env est absent, si ses droits ≠ 600, si une variable requise est vide, s'il contient une substitution $(…), si EMAIL_DRY_RUN=true avec APP_ENV=production, ou si SITE_URL ≠ https://$SITE_DOMAIN avec le profil proxy. Six exécutions de test.
AC-14.7-03 make deploy refuse de s'exécuter avec un arbre non propre ou hors de main, recalcule APP_VERSION après git pull, et conserve au plus 3 images de production (étiquettes SHA) ; make prune ne touche jamais aux étiquettes non SHA. make tags après trois déploiements successifs + essai sur arbre modifié.
AC-14.7-04 make rollback TAG=<sha> rend la version <sha> en < 60 s sans reconstruction et sans modifier le dépôt ; sans TAG, avec un tag qui n'est pas un SHA (par exemple e2e ou latest) ou avec un tag inconnu, la commande échoue avec un message d'usage. Exécution de test sur le VPS.
AC-14.9-01 Le VPS respecte le tableau §14.9 : seuls 22, 80, 443/tcp et 443/udp ouverts ; SSH par clé uniquement ; mises à jour automatiques actives ; Docker démarre au boot. ufw status, sshd -T, systemctl is-enabled docker, scan de ports externe.
AC-14.10-01 DNS : apex et www résolvent vers le VPS ; SPF unique, DKIM, DMARC publiés ; domaine « authentifié » dans Brevo ; un e-mail de test affiche SPF/DKIM/DMARC PASS. dig, tableau de bord Brevo, en-têtes du message.
AC-14.11-01 Le runbook §14.11 appliqué sur un VPS vierge aboutit à un site HTTPS fonctionnel (hors-ligne → en ligne) sans autre information que le dépôt, .env.example et ce document ; il ne demande jamais EMAIL_DRY_RUN=true en production ni une substitution de commande dans .env. Objectif de recette indicatif : 60 minutes de travail, à consigner dans docs/go-live-report.md ; un dépassement n'est pas un défaut bloquant. Répétition de déploiement à blanc.
AC-14.11-02 Après redémarrage du serveur (reboot), web et caddy redeviennent « healthy » sans intervention. Étape 10 du runbook.
AC-14.11-03 Le formulaire envoie un e-mail réel à astheoris.pro@gmail.com (avec Reply-To = visiteur) et l'accusé de réception arrive au visiteur ; la 6e soumission rapprochée depuis la même IP renvoie 429 RATE_LIMITED. Étape 9 du runbook, puis test de charge manuel à 6 requêtes.
AC-14.12-01 Une mise à jour (make deploy) provoque une indisponibilité < 15 s (aucune réponse autre que 200/503 temporisé) et finit par make verify vert. Boucle curl pendant le déploiement.
AC-14.13-01 Un rollback est documenté, testé une fois avant la mise en production (déploiement d'un tag n puis retour au tag n-1), et la version retournée par /api/health correspond au tag visé. Test de répétition.
AC-14.14-01 Variante Coolify : le déploiement sans profil proxy aboutit à un service web sain, accessible via le domaine Coolify, et les secrets BREVO_API_KEY/LOG_SALT ne sont pas disponibles au build. Configuration Coolify + inspection de l'historique de build.
AC-14.14-02 Variante proxy existant : web n'est joignable que via 127.0.0.1:${WEB_PORT} ; avec TRUST_PROXY=true, l'adresse limitée est celle du client (la dernière valeur de X-Forwarded-For, jamais une valeur fournie par le visiteur). ss -tlnp, test d'usurpation de X-Forwarded-For sur le proxy.
AC-14.14-03 Variante Coolify : avec l'option « Connect to Predefined Network » activée et sans réseau nommé imposé par compose.yaml, le domaine de la ressource répond 200 (pas de 502). Appel du domaine après le premier déploiement.
AC-14.15-01 Le workflow contient les jobs quality, unit-integration, e2e (accessibilité incluse), lighthouse, security, docker-build, visual (branche main seulement) et release-gate avec les dépendances décrites ; permissions limitées à contents: read ; aucun secret requis. Revue de .github/workflows/ci.yml.
AC-14.15-02 Le job lighthouse échoue si Performance < 0,9, Accessibilité < 1, Bonnes pratiques < 0,95, SEO < 1, LCP > 2,5 s, CLS > 0,05, TBT > 200 ms ou JS de script > 170 Ko sur / ; il utilise l'unique lighthouserc.json et réussit sur un build staging non indexable. Test négatif (image volontairement dégradée).
AC-14.15-03 Une PR dont le titre n'est pas Conventional Commits échoue au job quality. PR de test.
AC-14.15-04 Le job release-gate échoue sur un tag tant que les éléments légaux, de licence, d'assets et de validation de production sont incomplets, et réussit une fois fournis. Exécution sur tag de test.
AC-14.15-05 Le job security échoue sur une vulnérabilité de production de gravité haute ou critique, sur une clé xkeysib- présente dans le dépôt ou sur un .dockerignore qui n'exclut pas **/.env et .git. Test négatif sur branche jetable.
AC-14.16-01 Aucune donnée de visiteur n'est présente sur le VPS (hors journaux sans PII) ; la procédure de reconstruction complète ne requiert que Git, .env et la clé API Brevo. Revue + répétition de déploiement à blanc.
AC-14.17-01 La vérification externe de /api/health est active, alerte l'opérateur et Malka après 2 échecs consécutifs, et le test d'arrêt volontaire de web (docker compose stop web) déclenche l'alerte en ≤ 10 minutes. Test d'alerte à la mise en production.
AC-14.17-02 docker compose logs --no-log-prefix web produit exclusivement des lignes JSON valides (jq . sans erreur) ne contenant ni nom, ni e-mail, ni téléphone, ni message de visiteur. Soumission de test + recherche des valeurs saisies dans les journaux.

15. Stratégie de tests et critères d'acceptation #

Cette section définit comment le site est prouvé conforme : quels tests écrire, avec quels outils, sur quelles données, à quel moment ils bloquent la livraison, et quels critères d'acceptation consolidés déclarent le projet « terminé ». Elle est le propriétaire unique de la matrice de tests. Les règles fonctionnelles testées sont définies dans leurs sections propriétaires (renvoyées à chaque fois) ; la présente section ne les redéfinit pas, elle fixe les cas de test qui les vérifient.

Vocabulaire : « bloquant » = l'échec empêche la fusion d'une modification ; « porte » = contrôle exécuté avant une étape de livraison (§15.11) ; « hook de test » = attribut data-testid posé sur un élément (§15.2.5).

15.1 Principes et pyramide de tests #

15.1.1 Principes #

  1. Déterminisme. Aucun test n'appelle un service externe réel (Brevo, WhatsApp, Umami, Google Fonts). Les polices sont auto-hébergées au build (voir §9.1 et §11) ; Brevo est simulé par un fetch substitué ; wa.me est intercepté côté navigateur.
  2. Aucune donnée personnelle réelle. Les jeux de test utilisent exclusivement des domaines réservés (example.test, example.invalid) et des adresses IP de documentation (bloc 203.0.113.0/24). Le numéro de Malka n'est jamais utilisé comme entrée de test de formulaire ; il n'apparaît que dans les assertions sur les liens wa.me et tel:.
  3. Les contrats priment sur l'implémentation. Les tests d'intégration interrogent la route POST /api/contact selon le contrat de §9.4 (codes, enveloppe d'erreur, en-têtes), pas selon la structure interne des modules.
  4. Un comportement, un propriétaire de test. Chaque règle est testée au niveau le plus bas qui la prouve ; les niveaux supérieurs ne la re-testent que comme parcours utilisateur.
  5. Tout état est testé : chargement, vide, erreur, succès, désactivé, mouvement réduit, mobile, clavier, tactile (voir §15.12 pour la matrice des états).
  6. Les fixtures ne quittent jamais tests/ (garde-fous en §15.10).
  7. Les seuils ne se baissent pas pour faire passer un test. Toute dérogation est enregistrée dans DECISIONS.md (voir §18.7) avec date, motif et date de réexamen.

15.1.2 Pyramide #

                      ▲
                     ╱ ╲        Manuel (checklist go-live, lecteurs d'écran,
                    ╱ M ╲       envoi réel vers la boîte de Malka)         ~ 1 passage / version
                   ╱─────╲
                  ╱  E2E  ╲     Playwright : 5 projets navigateur/appareil,
                 ╱ + a11y  ╲    ~40 parcours, axe, visuel, Lighthouse CI   ~ 8–12 min
                ╱───────────╲
               ╱ Intégration ╲  Route Handlers + Brevo simulé + health
              ╱  (API, Vitest) ╲                                           ~ 40–60 cas, < 30 s
             ╱─────────────────╲
            ╱     Composants     ╲ Testing Library : boussole, orientation,
           ╱   (jsdom, Vitest)     ╲ carrousel, formulaire, menu            ~ 60–90 cas, < 45 s
          ╱─────────────────────────╲
         ╱         Unitaires          ╲ schémas, formats, liens WhatsApp,
        ╱   (Vitest, environnement node) ╲ limiteur, anti-spam, env, e-mail  ~ 120–180 cas, < 20 s
       ╱─────────────────────────────────╲
Niveau Outil (voir §9.1) Périmètre Volume cible Budget de durée Bloquant
Statique ESLint (flat config), Prettier, TypeScript strict Syntaxe, types, règles d'import, absence de fixtures dans src/ n.a. < 60 s Oui
Unitaire Vitest, environnement node Fonctions pures et modules serveur sans I/O réseau 120–180 < 20 s Oui
Composant Vitest + Testing Library + user-event, environnement jsdom Composants React interactifs, états, clavier 60–90 < 45 s Oui
Intégration Vitest (node), fetch substitué POST /api/contact, GET /api/health, journalisation 40–60 < 30 s Oui
E2E Playwright (Chromium, Firefox, WebKit, émulations mobiles) Parcours utilisateur sur le build réel (conteneurs Compose web-e2e et web-e2e-fixtures, §15.6.2) ~45 parcours 8–12 min Oui
Accessibilité auto @axe-core/playwright Toutes les routes et tous les états listés en §15.7 ~25 scans 2–3 min Oui (0 grave/critique)
Performance Lighthouse CI Budgets de §11 sur le build réel 3 URL × 5 passes 4–6 min Oui
Visuel Playwright toHaveScreenshot Hero et footer à 3 largeurs, Chromium Linux 6 captures 1 min Oui sur la branche principale
Manuel Checklist (§15.7.3) Lecteurs d'écran, zoom, envoi réel n.a. n.a. Porte de mise en production

Il n'existe qu'une configuration par outil : un seul vitest.config.ts (trois projets, §15.2.4), un seul playwright.config.ts (§15.2.4), un seul lighthouserc.json (§15.8.1). Ces trois fichiers sont la référence ; §12 et §14 s'y réfèrent sans les dupliquer.

15.1.3 Seuils de couverture (Vitest, fournisseur v8) #

Périmètre (glob Vitest) Lignes Branches Fonctions
src/lib/** ≥ 90 % ≥ 85 % ≥ 90 %
src/app/api/** ≥ 95 % ≥ 90 % 100 %
src/components/{sections/method,sections/orientation,sections/testimonials,forms,layout}/** (boussole, orientation, carrousel, formulaire, menu mobile) ≥ 85 % ≥ 75 % ≥ 85 %
Global ≥ 85 % ≥ 75 % ≥ 85 %

Ces seuils sont exprimés par glob dans coverage.thresholds de l'unique vitest.config.ts (§15.2.4) : un seuil global unique ne suffirait pas à exprimer les exigences par périmètre. La couverture est une condition nécessaire, pas suffisante : les cas listés en §15.3 à §15.5 sont obligatoires même si le seuil est déjà atteint.

AC-15.1-01 pnpm test:unit, pnpm test:components et pnpm test:integration s'exécutent sans accès réseau (vérifié en les lançant dans un conteneur sans réseau : docker run --network none). AC-15.1-02 Les seuils de §15.1.3 sont configurés par glob dans vitest.config.ts (coverage.thresholds) et font échouer la commande s'ils ne sont pas atteints. AC-15.1-03 Aucun test ne contient test.only, describe.only, it.only ni fit (règle ESLint no-restricted-syntax ciblant ces appels dans tests/**, définie dans l'unique configuration de §12.8.1 ; aucune dépendance supplémentaire). AC-15.1-04 Tout test.skip ou test.fixme est accompagné d'un commentaire // DECISION DEC-NNN renvoyant à une entrée de DECISIONS.md ; le test d'outillage tests/unit/tooling/skip-decision.test.ts échoue sinon.

15.2 Outillage, scripts et organisation #

15.2.1 Outils #

Les versions sont fixées en §9.1 (lignes majeures des paquets de test comprises) ; la présente section ne les répète pas.

Outil Rôle Fichier de configuration
Vitest Unitaire, composant, intégration (trois « projects ») vitest.config.ts, tests/fixtures/setup-node.ts
@testing-library/react, @testing-library/user-event, @testing-library/jest-dom Rendu et interactions réalistes des composants tests/fixtures/setup-dom.ts
jsdom Environnement DOM des tests de composants vitest.config.ts
Couverture v8 de Vitest Rapport et seuils vitest.config.ts
Playwright E2E, scans d'accessibilité, visuel, mesures de mise en page playwright.config.ts
@axe-core/playwright Scans WCAG automatisés tests/e2e/a11y/
Lighthouse CI (@lhci/cli) Budgets de performance, accessibilité, SEO, bonnes pratiques lighthouserc.json
ESLint, Prettier Qualité statique eslint.config.mjs, .prettierrc.json

Les paquets d'appoint de test (jsdom, @vitejs/plugin-react, user-event, jest-dom, couverture v8, @playwright/test) sont des dépendances de développement dont la ligne majeure est fixée en §9.1. Toute autre dépendance de test est interdite sans entrée dans DECISIONS.md (voir §18.6).

15.2.2 Scripts package.json obligatoires #

Script Action Utilisé par
lint eslint . --max-warnings 0 make lint, CI (job quality)
format:check prettier --check . CI (job quality)
typecheck tsc --noEmit make typecheck, CI (job quality)
test vitest run (les trois projets) local
test:unit vitest run --project unit make test
test:components vitest run --project components make test
test:integration vitest run --project integration make test
test:coverage vitest run --coverage CI (job unit-integration)
test:e2e playwright test --grep-invert @visual make test-e2e
test:a11y playwright test --project=chromium-desktop --grep @a11y usage ciblé (les scans @a11y sont aussi exécutés par test:e2e dans le job e2e)
test:visual playwright test --project=chromium-desktop --grep @visual CI (job visual, Linux uniquement)
test:visual:update idem avec --update-snapshots exécuté dans le conteneur Playwright uniquement
lighthouse lhci autorun --config=lighthouserc.json make lighthouse, CI (job lighthouse)
check:bundle tsx scripts/check-bundle-size.ts (budget JS de §11) CI (job docker-build), make lighthouse
check:guards tsx scripts/check-assets.ts --guards-only (garde-fous G-00 à G-11 de §9.3.7, voir §15.10.4) CI (job quality)
check:no-fixtures tsx scripts/check-no-fixtures.ts (voir §15.10.3) CI (job docker-build)

Ces scripts sont l'union de la liste de §12.9 (qui reprend exactement ce tableau) : il n'existe pas de script de préparation de variantes d'E2E, les deux variantes étant des services Compose (§15.6.2). make test enchaîne test:unit, test:components, test:integration. make test-e2e construit les images de test et exécute test:e2e dans le profil Compose e2e (voir §14). Le script lint est appelé avec --max-warnings 0 partout (local, CI, Docker).

15.2.3 Organisation des fichiers de test #

tests/
├── unit/                    # *.test.ts — environnement node
│   ├── content/             # schémas, refinements, règle « publiable », parsing des fichiers livrés
│   ├── format/              # prix, typographie, WhatsApp
│   ├── security/            # limiteur, anti-spam, échappement e-mail, en-têtes
│   ├── config/              # env, garde-fous de production
│   ├── analytics/           # track() no-op
│   └── tooling/             # structure du dépôt, scripts de contrôle (exécutés par le job `quality`, jamais dans l'étape Docker `test`)
├── components/              # *.test.tsx — environnement jsdom
├── integration/             # *.test.ts — route handlers
│   └── helpers/             # constructeurs de requêtes, simulateur Brevo, capture de `after()`
├── e2e/
│   ├── helpers/             # étapes communes, mesures de géométrie, wrapper axe
│   ├── journeys/            # *.spec.ts (un fichier par domaine)
│   ├── a11y/                # *.spec.ts axe
│   ├── visual/              # *.spec.ts + __screenshots__/
│   └── global-setup.ts
└── fixtures/
    ├── content/             # jeux de contenu de test (voir §15.10.1)
    ├── legal/
    ├── setup-dom.ts, setup-node.ts, server-only-stub.ts   # fichiers d'amorçage Vitest (§12.8.5)
    └── images/              # image d'essai pour les scripts de contrôle

L'emplacement racine tests/ est la règle ; §12.2 reprend cette arborescence. Les garde-fous de production ne sont pas des fichiers de test : ce sont des contrôles exécutés par des scripts (scripts/check-assets.ts, scripts/check-no-fixtures.ts), eux-mêmes vérifiés par les tests GT-01 à GT-05 de §15.10.4.

15.2.4 Configuration de référence #

// vitest.config.ts (extrait normatif — unique configuration Vitest ; le fichier complet est celui de §12.8.5)
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'node:path';

const alias = {
  '@': path.resolve(__dirname, 'src'),
  'server-only': path.resolve(__dirname, 'tests/fixtures/server-only-stub.ts'),
};

export default defineConfig({
  plugins: [react()],
  resolve: { alias },
  test: {
    projects: [
      { extends: true, test: { name: 'unit', environment: 'node', include: ['tests/unit/**/*.test.ts'], setupFiles: ['tests/fixtures/setup-node.ts'] } },
      { extends: true, test: { name: 'components', environment: 'jsdom', include: ['tests/components/**/*.test.tsx'], setupFiles: ['tests/fixtures/setup-dom.ts'] } },
      { extends: true, test: { name: 'integration', environment: 'node', include: ['tests/integration/**/*.test.ts'], setupFiles: ['tests/fixtures/setup-node.ts'], pool: 'forks' } },
    ],
    coverage: {
      provider: 'v8',
      include: ['src/lib/**', 'src/app/api/**', 'src/components/**'],
      thresholds: {
        lines: 85, branches: 75, functions: 85,
        'src/lib/**':     { lines: 90, branches: 85, functions: 90 },
        'src/app/api/**': { lines: 95, branches: 90, functions: 100 },
        'src/components/{sections/method,sections/orientation,sections/testimonials,forms,layout}/**': { lines: 85, branches: 75, functions: 85 },
      },
    },
    restoreMocks: true,
    unstubEnvs: true,
    unstubGlobals: true,
  },
});

L'alias server-only est indispensable : sans lui, tout module qui commence par import 'server-only' (env, journal, e-mail, limiteur, contrôleur d'API) lève une exception hors du rendu serveur de Next.js (§12.3.3).

// playwright.config.ts (extrait normatif)
import { defineConfig, devices } from '@playwright/test';

// URLs injectées par le service Compose `e2e` (§15.6.2) ; aucune valeur par défaut codée en dur.
const BASE = process.env.PLAYWRIGHT_BASE_URL!;  // service `web-e2e` : contenu livré
// Les parcours marqués [fixtures] fixent `test.use({ baseURL: process.env.PLAYWRIGHT_FIXTURES_URL })`
// (service `web-e2e-fixtures` : témoignages publiables, légal complet, mesure d'audience fictive).

export default defineConfig({
  testDir: 'tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: { baseURL: BASE, locale: 'fr-FR', timezoneId: 'Europe/Paris', trace: 'retain-on-failure' },
  expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.02, animations: 'disabled', caret: 'hide' } },
  projects: [
    { name: 'chromium-desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
    { name: 'firefox-desktop',  use: { ...devices['Desktop Firefox'], viewport: { width: 1440, height: 900 } }, grep: /@smoke|@cross/ },
    { name: 'webkit-desktop',   use: { ...devices['Desktop Safari'], viewport: { width: 1440, height: 900 } }, grep: /@smoke|@cross/ },
    { name: 'mobile-chrome',    use: { ...devices['Pixel 7'] }, grepInvert: /@desktop-only|@visual/ },
    { name: 'mobile-safari',    use: { ...devices['iPhone 14'] }, grep: /@smoke|@cross|@mobile/ },
  ],
});

Il n'y a aucun webServer : les deux serveurs de test sont des services Compose démarrés avant le lanceur (§15.6.2, §14.4). Chacun tourne avec APP_ENV=staging, EMAIL_DRY_RUN=true, TRUST_PROXY=true, SEND_ACKNOWLEDGEMENT=true, un LOG_SALT de test et un SITE_URL égal à l'URL que le navigateur de test utilise (http://web-e2e:3000 et http://web-e2e-fixtures:3000) : le contrôle d'origine de §9.4 compare l'en-tête Origin à la valeur de SITE_URL figée au build, une différence provoque un 403 sur toute soumission.

15.2.5 Contrat des hooks de test #

Les composants exposent les data-testid suivants (invisibles, sans effet sur l'accessibilité). Les libellés visibles restent ceux de §7 ; les tests utilisent d'abord les rôles et noms accessibles, et ne recourent aux hooks que pour les zones sans nom stable.

Hook Élément
site-header, nav-link-<anchorId>, nav-cta, mobile-menu-button, mobile-menu Navigation (§6.1)
hero, hero-headline, hero-description, hero-portrait, hero-clarte-word, hero-rail, hero-cta-primary, hero-cta-secondary, hero-trust, hero-layer-far, hero-layer-mid, hero-layer-near, hero-band Hero (§6.2)
method-compass, method-step-<stepId>, method-panel Méthode (§6.4)
offers-list, offer-item-<offerId>, offer-whatsapp-<offerId>, offer-form-<offerId>, offers-vat-mention Accompagnements (§6.5)
orientation-radiogroup, orientation-option-<situationId>, orientation-result, orientation-contact-cta Orientation (§6.6)
testimonials-section, testimonial-carousel (absent quand un seul témoignage est publiable : citation statique rendue par testimonials-section), testimonial-slide-<id> Témoignages (§6.8)
contact-form, contact-situation, contact-submit, contact-status, contact-error-summary, contact-success, contact-fallback, contact-whatsapp Contact (§6.9)
site-footer, footer-cta Footer (§6.10)
legal-dev-banner Bandeau dev des pages légales (§6.11)

Les <stepId> sont ecouter, explorer, clarifier, decider ; les <anchorId> sont ceux de §4.2 ; les <situationId> et <offerId> sont ceux de §9.3.

AC-15.2-01 Les scripts de §15.2.2 (nombre = lignes du tableau) existent dans package.json et retournent un code de sortie non nul en cas d'échec. AC-15.2-02 Les cinq projets Playwright de §15.2.4 sont déclarés avec les périphériques Pixel 7 et iPhone 14 pour l'émulation mobile. AC-15.2-03 Chaque hook de §15.2.5 est présent dans le DOM rendu de la route concernée (test E2E hooks.spec.ts qui vérifie leur présence dans la variante appropriée). AC-15.2-04 playwright.config.ts interdit test.only en CI (forbidOnly), limite à 1 le nombre de reprises et ne déclare aucun webServer.

15.3 Tests unitaires #

Environnement node, sans réseau, sans horloge réelle (horloge simulée avec vi.useFakeTimers() et vi.setSystemTime() pour tout test dépendant du temps). Chaque ligne ci-dessous est un cas obligatoire ; des cas supplémentaires sont bienvenus.

15.3.1 Schémas de contenu et refinements (propriétaire de la règle : §9.3) #

ID Cas Résultat attendu
U-CNT-01 Chaque fichier de src/content/fr/ (voir §9.3) est chargé via getContent('fr') Aucune exception ; objet typé retourné
U-CNT-17 Pour chacun des 13 fichiers de src/content/fr/ (site, navigation, hero, philosophy, method, offers, orientation, about, testimonials, contact, footer, legal, seo), un test nommé d'après le fichier applique le schéma strict correspondant de §9.3.3 à l'export livré Chaque fichier est valide ; aucune clé inconnue, aucune clé requise absente ; l'échec nomme le fichier et le chemin du champ fautif (un fichier livré qui ne satisfait pas son schéma fait donc échouer la suite avant tout build)
U-CNT-18 getContent('fr') avec APP_ENV=production hors phase de build (NEXT_PHASE différent de phase-production-build), système de fichiers simulé et sans content-validation.json ni registre de licences Aucune lecture de fichier ; aucune exception pour absence de registre (les gardes qui lisent le système de fichiers ne s'exécutent qu'au build, §9.3.7)
U-CNT-02 offers livré Exactement 4 offres, ids dans l'ordre seance-clarification, lecture-globale, suivi-transition, accompagnement-personnalise ; order strictement croissant et sans doublon
U-CNT-03 Prix livrés 300, 150, 1000 en kind:'fixed' ; accompagnement-personnalise en kind:'quote' avec le libellé « Sur devis »
U-CNT-04 Offre avec amountEur négatif, nul, décimal ou NaN Rejet par le schéma
U-CNT-05 Deux offres avec le même id Rejet (message nommant l'id dupliqué)
U-CNT-06 whatsappMessage vide ou ne contenant que des espaces Rejet
U-CNT-07 orientation : suggestedOfferId inexistant dans offers Rejet par le refinement inter-fichiers
U-CNT-08 orientation : situations livrées Exactement decision-bloquee, fatigue, repositionnement, transition ; correspondances vers seance-clarification, lecture-globale, lecture-globale, suivi-transition
U-CNT-09 orientation : questions à 1 ou 4 éléments Rejet (2 à 3 exigés)
U-CNT-10 orientation : prompt d'une seule phrase ou de plus de quatre phrases Rejet (2 à 4 phrases, décompte par terminaisons ., !, ?, …)
U-CNT-11 site.trustFigures livré {value:300, suffix:'+'} et {value:50, suffix:'+'} ; aucune autre statistique
U-CNT-12 site.contact livré phoneE164 === '+33667295179', whatsappE164Digits === '33667295179', email === 'astheoris.pro@gmail.com'
U-CNT-13 Cohérence phoneE164 / whatsappE164Digits phoneE164.slice(1) === whatsappE164Digits ; sinon rejet
U-CNT-14 testimonials livré 2 entrées (stephanie, emilie), toutes published:false, consent.status:'pending', quote:''
U-CNT-15 Chargeur : un fichier de contenu invalide getContent lève une erreur dont le message nomme le fichier et le chemin du champ fautif
U-CNT-16 Aucun fichier de contenu ne contient de caractère de contrôle ni de balise HTML Tous les champs texte passent le test !/<[a-z!/]/i

15.3.2 Règle « témoignage publiable » (propriétaire : §9.3) #

ID Entrée Attendu
U-TST-01 published:true, consent:{status:'granted', grantedOn:'2026-01-15'}, quote:'Texte de test' Schéma valide ; isPublishable === true
U-TST-02 published:true + consent.status:'pending' Schéma invalide
U-TST-03 published:true + granted mais grantedOn absent Schéma invalide
U-TST-04 published:true + granted + grantedOn mal formé ('15/01/2026', '2026-13-40', '2026-02-30') Schéma invalide
U-TST-05 published:true + granted + quote:'' ou quote:' ' Schéma invalide
U-TST-06 published:false + granted + quote renseignée Schéma valide ; isPublishable === false
U-TST-07 grantedOn dans le futur Schéma invalide
U-TST-08 Tableau sans aucun témoignage publiable getPublishableTestimonials() retourne [] ; le drapeau de rendu de la section est faux
U-TST-09 Tableau avec 2 publiables et 1 non publiable Retourne les 2 publiables, dans l'ordre du fichier
U-TST-10 Deux témoignages de même id Schéma invalide
U-TST-11 published:true + granted + quote de 1 caractère, puis de 700 caractères Schéma valide dans les deux cas (aucune longueur minimale)
U-TST-12 published:true + granted + quote de 701 caractères Schéma invalide

15.3.3 Formatage des prix fr-FR (propriétaire : §9.3) #

ID Entrée Sortie attendue (exacte)
U-FMT-01 {kind:'fixed', amountEur:300} "300\u00A0€" (U+00A0 avant « € »)
U-FMT-02 {kind:'fixed', amountEur:150} "150\u00A0€"
U-FMT-03 {kind:'fixed', amountEur:1000} "1\u202F000\u00A0€" (espace fine insécable en séparateur de milliers, espace insécable avant « € »)
U-FMT-04 {kind:'quote', label:'Sur devis'} "Sur devis"
U-FMT-05 {kind:'fixed', amountEur:1234567} "1\u202F234\u202F567\u00A0€"
U-FMT-06 Aucune sortie ne contient de décimales (,00) Vrai pour toutes les entrées entières
U-FMT-07 Aucune sortie ne contient d'espace ordinaire (U+0020) entre le nombre et le symbole Vrai

15.3.4 Constructeur de lien WhatsApp (propriétaire : §6.5, §9.8 pour l'événement) #

Le constructeur est la seule fonction autorisée à produire une URL wa.me (module src/lib/booking/, voir §9.2). Les composants n'assemblent jamais d'URL.

ID Entrée (message) Attendu
U-WA-01 Bonjour Malka, je souhaite en savoir plus sur « Séance de clarification ciblée ». Pouvons-nous échanger ? (entrée volontairement écrite avec des espaces ordinaires : le constructeur n'altère jamais le message) https://wa.me/33667295179?text=Bonjour%20Malka%2C%20je%20souhaite%20en%20savoir%20plus%20sur%20%C2%AB%20S%C3%A9ance%20de%20clarification%20cibl%C3%A9e%20%C2%BB.%20Pouvons-nous%20%C3%A9changer%20%3F
U-WA-02 Message contenant &, #, %, +, = Chacun encodé (%26, %23, %25, %2B, %3D)
U-WA-03 Message contenant un retour à la ligne \n Encodé %0A
U-WA-04 Message contenant l'émoji 🙂 Encodé %F0%9F%99%82
U-WA-05 Message contenant une apostrophe droite ' Laissée telle quelle (comportement d'encodeURIComponent)
U-WA-06 Pour chaque offre de offers, href produit Égal à https://wa.me/33667295179?text=${encodeURIComponent(offer.whatsappMessage)} calculé indépendamment dans le test
U-WA-07 Numéro L'URL ne contient ni +, ni espace, ni tiret dans la partie numéro ; elle utilise whatsappE164Digits
U-WA-08 Aucun appel à encodeURI ni remplacement manuel de caractères Vérifié par une règle ESLint no-restricted-syntax ciblant encodeURI( dans src/lib/booking/
U-WA-09 getBookingAction({ offerId }) avec un offerId valide { kind:'whatsapp', href, label } avec href conforme à U-WA-06
U-WA-10 getBookingAction({ offerId:'inconnu' }) Retombe sur l'action générique (message par défaut de contact), sans lever d'exception ni refléter la valeur reçue
U-WA-11 getBookingAction({}) Action générique de contact
U-WA-12 Message contenant U+00A0 et U+202F (typographie française de §7) Encodés %C2%A0 et %E2%80%AF ; le constructeur ne les remplace pas

15.3.5 Helper de typographie française (règles : §7) #

Le helper frTypo est celui de §5.5.6 : espace fine insécable U+202F avant ;, !, ? ; espace insécable U+00A0 avant : et à l'intérieur des guillemets « » ; les prix restent tels que produits par Intl (§15.3.3) ; les numéros de téléphone ne sont pas modifiés (ils sont protégés par white-space: nowrap en CSS). Les assertions écrivent les caractères par leurs échappements Unicode (\u202F, \u00A0), jamais en caractères invisibles littéraux.

ID Entrée Attendu
U-TYP-01 "Pourquoi ?", "Attention !", "Note ; suite" U+202F à la place de l'espace avant ?, !, ;
U-TYP-01b "Étape : un" U+00A0 à la place de l'espace avant :
U-TYP-02 "« Clarté »" U+00A0 après « et avant »
U-TYP-03 "Pourquoi?" (sans espace) U+202F est insérée avant ?
U-TYP-04 Idempotence fr(fr(x)) === fr(x) pour toutes les entrées ci-dessus
U-TYP-05 URL (https://exemple.test/a:b, https://exemple.test/a?b=1), e-mail (a@b.test), numéro 06 67 29 51 79 Inchangés
U-TYP-06 Chaîne vide, chaîne sans ponctuation, chaîne d'espaces Retournées sans exception
U-TYP-07 Aucune coupure de ligne possible avant la ponctuation haute Vérifié en composant : le texte rendu ne contient pas d'U+0020 devant ? ! ; :
U-TYP-08 Tous les titres et textes de src/content/fr/ Aucun espace + (? ! ; :) avec espace ordinaire dans les chaînes rendues (test de non-régression sur le contenu livré)

15.3.6 Limiteur de débit (propriétaire : §9.6) #

Le test utilise des horloges simulées. L'implémentation expose en outre une méthode de diagnostic size(): number (nombre de clés retenues), utilisée par les seuls tests ; elle n'est référencée par aucun code de production. Fenêtres : 30 tentatives / 10 min, 5 soumissions acceptées / 10 min et 20 / 24 h par IP ; coupe-circuit global de 200 / heure ; plafonds d'accusé de réception : 2 / 24 h par adresse, 5 / 24 h par IP et 30 / heure au total (valeurs de §9.6.2).

ID Scénario Attendu
U-RL-01 5 appels pour la même IP en moins de 10 min 5 × allowed
U-RL-02 6ᵉ appel dans la même fenêtre denied, retryAfterSeconds entier ≥ 1
U-RL-03 retryAfterSeconds Égal à ceil((tPremierAppel + 10 min − maintenant) / 1000)
U-RL-04 Fenêtre glissante : 5 appels à t=0, avancer à t=10 min + 1 s, 6ᵉ appel allowed
U-RL-05 Fenêtre glissante partielle : appels à t=0, 2 min, 4 min, 6 min, 8 min ; avancer à t=10 min + 1 s 1 créneau libéré ; le 6ᵉ appel est allowed, le 7ᵉ est denied
U-RL-06 Fenêtre 24 h : 20 appels étalés (jamais plus de 5 par 10 min) puis 21ᵉ denied avec retryAfterSeconds calculé sur la fenêtre 24 h
U-RL-07 Deux IP distinctes Compteurs indépendants
U-RL-08 Coupe-circuit : 200 appels en une heure répartis sur 40 IP, 201ᵉ appel d'une 41ᵉ IP denied
U-RL-09 Coupe-circuit : récupération après 1 h + 1 s allowed
U-RL-10 TRUST_PROXY=true : X-Forwarded-For: 198.51.100.9, 203.0.113.7 Clé = 203.0.113.7 (dernière entrée valide : celle écrite par l'unique proxy de confiance ; la première, fournie par le client, est ignorée)
U-RL-10b TRUST_PROXY=true : X-Forwarded-For: 203.0.113.7 Clé = 203.0.113.7
U-RL-11 TRUST_PROXY=false avec X-Forwarded-For présent En-tête ignoré ; clé = adresse de connexion ou unknown
U-RL-12 X-Forwarded-For malformé ("", " ", "not-an-ip") avec TRUST_PROXY=true Retombe sur la clé unknown ; aucune exception
U-RL-13 Purge : après 24 h + 1 s sans activité puis un appel size() ne retient que les clés actives
U-RL-14 Journalisation : l'événement de refus contient ipHash (12 caractères hexadécimaux) et jamais l'IP en clair Vérifié par capture de la sortie standard
U-RL-15 Retry-After quand plusieurs règles refusent : accepted_10m (libération dans 3 min) et accepted_24h (libération dans 6 h) saturées simultanément denied, rule = accepted_24h, retryAfterSeconds = 21 600 (maximum parmi les règles refusantes)
U-RL-16 Plafonds d'accusé : 3ᵉ accusé pour la même adresse en 24 h ; 6ᵉ accusé pour la même IP en 24 h ; 31ᵉ accusé au total en 1 h Chacun denied (règles ack_email_24h, ack_ip_24h, ack_global_1h) ; les fenêtres de soumissions acceptées ne sont pas affectées

15.3.7 Heuristiques anti-spam (propriétaire : §9.6) #

ID Entrée Attendu
U-SP-01 website:'x' (honeypot rempli) spam
U-SP-02 website:' ' (espaces) spam (toute valeur non vide, même d'espaces, est suspecte)
U-SP-03 Date.now() − startedAt = 2999 spam
U-SP-04 Date.now() − startedAt = 3000 ok
U-SP-05 startedAt dans le futur (Date.now() + 5000) spam
U-SP-06 Date.now() − startedAt = 86 400 000 (24 h exactement) ok
U-SP-07 Date.now() − startedAt = 86 400 001 spam
U-SP-08 Soumission légitime (honeypot vide, 10 s) ok

15.3.8 Analyse de l'environnement (propriétaire : §13) #

La fonction pure d'analyse exportée par src/lib/env.ts est testée sans process.exit : l'enveloppe qui quitte le processus est testée séparément avec process.exit simulé.

ID Entrée Attendu
U-ENV-01 Environnement minimal de développement Valeurs par défaut : EMAIL_FROM_NAME = « Malka Debleds », CONTACT_TO_EMAIL = astheoris.pro@gmail.com, SEND_ACKNOWLEDGEMENT vrai, TRUST_PROXY vrai, LOG_LEVEL = info, PORT = 3000, HOSTNAME = 0.0.0.0
U-ENV-02 EMAIL_DRY_RUN absent Vrai en development, en test et en staging ; faux en production
U-ENV-02b APP_ENV=production avec EMAIL_DRY_RUN=true Échec au démarrage ; le message nomme EMAIL_DRY_RUN
U-ENV-03 APP_ENV=production sans BREVO_API_KEY et EMAIL_DRY_RUN=false Échec ; le message nomme BREVO_API_KEY
U-ENV-04 APP_ENV=production sans LOG_SALT Échec ; le message nomme LOG_SALT
U-ENV-05 APP_ENV=production sans EMAIL_FROM_ADDRESS Échec ; le message nomme EMAIL_FROM_ADDRESS
U-ENV-06 Plusieurs variables manquantes Un seul message listant toutes les variables manquantes
U-ENV-07 Le message d'erreur ne contient jamais la valeur d'une variable secrète Test avec BREVO_API_KEY='canari-secret-123' invalide par ailleurs : la chaîne canari-secret-123 est absente du message
U-ENV-08 Booléens 'true' et 'false' acceptés ; 'yes', '1', '' rejetés avec message nommant la variable
U-ENV-09 SITE_URL URL absolue http(s) exigée ; 'example', 'ftp://x.test' rejetés ; barre oblique finale normalisée
U-ENV-10 APP_ENV hors `development staging
U-ENV-11 PORT non numérique ou hors 1–65535 Échec
U-ENV-12 CONTACT_TO_EMAIL et EMAIL_FROM_ADDRESS non conformes à une adresse e-mail Échec
U-ENV-13 Aucune variable NEXT_PUBLIC_* n'est lue Test statique : recherche textuelle de NEXT_PUBLIC_ dans src/ = 0 occurrence
U-ENV-14 Cohérence de l'adresse de réception La valeur par défaut de CONTACT_TO_EMAIL dans src/lib/env.ts et la valeur de CONTACT_TO_EMAIL dans .env.example sont égales à site.contact.email

15.3.9 Gabarits d'e-mail et échappement (propriétaire : §9.5) #

ID Cas Attendu
U-EML-01 name = '<script>alert(1)</script>' HTML : &lt;script&gt;alert(1)&lt;/script&gt; ; aucune balise <script dans la sortie ; texte brut : valeur littérale
U-EML-02 message = '"><img src=x onerror=alert(1)>' Guillemets, chevrons échappés (&quot;, &gt;, &lt;)
U-EML-03 message = 'a & b &amp; c' a &amp; b &amp;amp; c (pas de double décodage)
U-EML-04 message multiligne HTML : sauts de ligne convertis en <br> après échappement ; texte brut : sauts conservés
U-EML-05 Objet : name = 'Camille\r\nBcc: attaquant@example.invalid' Le sujet ne contient aucun \r ni \n ; le schéma de §9.4 rejette de toute façon ce nom en amont (cas couvert aussi en intégration)
U-EML-06 Format du sujet Exactement Nouvelle demande — {titre de l'offre ou « Prise de contact »} — {nom} avec tiret cadratin (U+2014)
U-EML-07 offerId = 'non-defini' ou absent Titre « Prise de contact »
U-EML-08 replyTo Égal à l'e-mail du visiteur (en minuscules, tel que normalisé)
U-EML-09 Accusé de réception Aucun champ saisi par le visiteur (nom, message, téléphone, offre, situation) n'apparaît dans le sujet, le HTML ou le texte brut ; test avec valeurs canari
U-EML-10 Accusé de réception Contient la signature « Malka Debleds », le numéro WhatsApp et le téléphone, tous issus de site.contact
U-EML-11 Charge utile Brevo Contient sender, to, replyTo (notification uniquement), subject, htmlContent, textContent
U-EML-12 Longueur Le HTML d'une notification avec message de 2000 caractères reste < 100 Ko

15.3.10 Schéma de la requête de contact (propriétaire : §9.4) #

ID Champ / valeur Attendu
U-CTC-01 name : 'A', '', ' A ' Rejet (moins de 2 caractères après trim)
U-CTC-02 name : 2 caractères, 80 caractères Valide
U-CTC-03 name : 81 caractères Rejet
U-CTC-04 email : 'Camille@Example.TEST' Valide ; valeur normalisée en camille@example.test
U-CTC-05 email : 'camille@', '@x.test', 'a b@x.test', 'a@b', adresse de 255 caractères Rejet
U-CTC-06 email contenant \r ou \n Rejet
U-CTC-07 phone absent ou '' Valide (optionnel)
U-CTC-08 phone : '06 12 34 56 78', '+33 6 12 34 56 78', '(01) 23-45.67' Valide
U-CTC-09 phone : '12345' (5), 'abc', 21 caractères Rejet
U-CTC-10 offerId : 4 ids valides + 'non-defini' ; 'inconnu' Valides ; rejet
U-CTC-11 situationId : 4 ids valides ; 'inconnu' Valides ; rejet
U-CTC-12 message : 19 caractères, 20 caractères, 2000, 2001 Rejet, valide, valide, rejet
U-CTC-13 message de 20 espaces ou de 25 espaces + 5 lettres Rejet (longueur mesurée après trim)
U-CTC-14 consent : true Valide
U-CTC-15 consent : false, 'true', 1, absent Rejet
U-CTC-16 website : absent ou non-chaîne Rejet (la présence du champ vide est requise)
U-CTC-17 startedAt : absent, chaîne, NaN, Infinity Rejet
U-CTC-18 Messages d'erreur des champs Tous en français et ne répètent jamais la valeur saisie
U-CTC-19 name : 'Camille\r\nBcc: x@example.invalid' Rejet (retour à la ligne : injection d'en-tête)
U-CTC-20 name contenant U+202E (inversion de sens d'écriture) ou U+200B Rejet (caractères de contrôle et de mise en forme bidirectionnelle)
U-CTC-21 message contenant 3 liens https://… ; puis 2 liens tooManyLinks pour 3 liens ; valide pour 2
U-CTC-22 phone : 6 chiffres, 15 chiffres, 16 chiffres Valide, valide, rejet

15.3.11 track() d'analyse (propriétaire : §9.8) #

ID Cas Attendu
U-AN-01 ANALYTICS_PROVIDER=none : track('cta_click', {cta:'hero_rdv'}) Retourne sans effet : aucun appel à window.umami, aucun fetch, aucune exception, aucune écriture de cookie ou de stockage
U-AN-02 Fournisseur umami, window.umami indéfini (script bloqué par un bloqueur) Aucune exception
U-AN-03 Fournisseur umami, window.umami.track simulé : cta_click avec cta:'offer_whatsapp' et offer_id Un appel avec exactement {cta, offer_id}
U-AN-04 Événement hors liste de §9.8 Ignoré
U-AN-05 Propriétés hors liste (situationId, email, name, message, phone) Retirées avant envoi ; l'événement part sans elles
U-AN-06 contact_submit Seule la propriété result (success ou error) est transmise
U-AN-07 method_step_open Seule la propriété step (parmi les 4 ids) est transmise ; valeur hors liste rejetée
U-AN-08 Composant du script d'analyse, fournisseur none Ne rend aucun <script>
U-AN-09 Composant du script d'analyse, fournisseur umami Rend <script defer> avec data-website-id, data-do-not-track="true", data-auto-track="true", data-exclude-search="true", data-exclude-hash="true" et la source UMAMI_SCRIPT_URL
U-AN-10 Générateur de la CSP (voir §10) Sans Umami : aucune origine d'analyse ; avec Umami : l'origine de UMAMI_SCRIPT_URL ajoutée aux seules directives nécessaires

15.3.12 Autres tests unitaires obligatoires #

ID Sujet Attendu
U-DS-01 Contrastes des jetons (§5) : calcul WCAG du rapport de contraste pour chaque paire texte/fond déclarée dans le tableau d'usage de §5 ≥ 4,5 pour le texte courant, ≥ 3 pour le grand texte et les composants d'interface ; le test échoue si une paire déclarée tombe sous le seuil
U-DS-02 Les valeurs des jetons CSS (--color-*) de globals.css sont égales à celles du tableau de §5 Égalité stricte (extraction par expression régulière)
U-GRD-01 Garde-fou G-02 (§9.3.7, « actifs de dev », voir §15.10.4) : une configuration référençant dev-fallback avec APP_ENV=production Échec ; message nommant le fichier
U-GRD-02 Garde-fou légal G-01 : APP_ENV=production avec legal.incomplete Échec ; message listant tous les champs manquants
U-GRD-03 Garde-fou légal G-01 : APP_ENV=production avec legal.complete Succès
U-GRD-04 Garde-fous : APP_ENV=development ou staging avec légal incomplet Succès (bandeau de développement)
U-GRD-05 Garde-fou « marqueur de fixture » (motifs [FIXTURE] et Auteur Fixture du détecteur de textes provisoires de §9.3.6) : l'une des chaînes dans un contenu avec APP_ENV=production Échec
U-SEO-01 Générateur de sitemap : URLs produites, dans tous les environnements Exactement /, /mentions-legales, /politique-de-confidentialite, préfixées par SITE_URL, sans fragment
U-SEO-02 Générateur de robots En production : autorise l'indexation et référence SITE_URL/sitemap.xml ; hors production : Disallow: / (protection avec noindex et X-Robots-Tag, §11.1.7)

AC-15.3-01 Tous les cas de §15.3.1 à §15.3.12 existent sous forme de tests nommés par leur ID (par exemple it('U-WA-01 …')). AC-15.3-02 La suite unitaire s'exécute en moins de 20 s sur la machine de CI de référence. AC-15.3-03 Aucun test unitaire n'importe depuis src/components/ ni ne dépend de window. AC-15.3-04 Les vecteurs d'or (golden vectors) U-WA-01 et U-FMT-01 à U-FMT-05 contiennent les chaînes littérales du tableau, non recalculées par la fonction testée. AC-15.3-05 Aucun fichier de test ni de configuration ne contient de caractère invisible littéral (U+200B à U+200F, U+202A à U+202F, U+2060 à U+2069, U+00A0, U+FEFF) : ils sont toujours écrits par leur séquence d'échappement \uXXXX (test d'outillage de tests/unit/tooling/).

15.4 Tests d'intégration de l'API #

Environnement node. Les handlers POST, GET (et les autres méthodes) de src/app/api/contact/route.ts ainsi que GET de src/app/api/health/route.ts sont importés et appelés avec des objets Request standard. Contrat testé : §9.4 (codes, enveloppe d'erreur), §9.5 (e-mails), §9.6 (limite et anti-spam), §9.7 (journaux).

15.4.1 Banc d'essai #

// tests/integration/helpers/harness.ts
import { vi } from 'vitest';

export const SITE_ORIGIN = 'https://site.example.test';

// `after()` de Next.js exécute l'accusé de réception après la réponse : le banc le remplace par une file
// que les tests vident explicitement avec `await flushAfter()` avant de vérifier le second appel Brevo.
const afterQueue: Array<() => unknown> = [];
vi.mock('next/server', async (orig) => ({
  ...(await orig<typeof import('next/server')>()),
  after: (task: () => unknown) => { afterQueue.push(task); },
}));
export async function flushAfter() {
  while (afterQueue.length) await afterQueue.shift()!();
}

export const BASE_ENV = {
  NODE_ENV: 'test',
  APP_ENV: 'staging',
  SITE_URL: SITE_ORIGIN,
  APP_VERSION: 'test-sha',
  BREVO_API_KEY: 'test-key-not-a-secret',
  EMAIL_FROM_ADDRESS: 'contact@site.example.test',
  EMAIL_FROM_NAME: 'Malka Debleds',
  CONTACT_TO_EMAIL: 'astheoris.pro@gmail.com',
  SEND_ACKNOWLEDGEMENT: 'true',
  EMAIL_DRY_RUN: 'false',
  TRUST_PROXY: 'true',
  LOG_LEVEL: 'info',
  LOG_SALT: '0123456789abcdef'.repeat(4),
} as const;

/** Recharge les modules avec un environnement donné : l'état du limiteur est neuf à chaque test. */
export async function loadRoute(env: Record<string, string> = {}) {
  vi.resetModules();
  for (const [k, v] of Object.entries({ ...BASE_ENV, ...env })) vi.stubEnv(k, v);
  return import('@/app/api/contact/route');
}

export function validBody(overrides: Record<string, unknown> = {}) {
  return {
    name: 'Camille Test',
    email: 'camille.test@example.test',
    phone: '06 00 00 00 00',
    offerId: 'seance-clarification',
    situationId: 'decision-bloquee',
    message: 'Ceci est un message de test suffisamment long pour être valide.',
    consent: true,
    website: '',
    startedAt: Date.now() - 10_000,
    ...overrides,
  };
}

export function postRequest(body: unknown, headers: Record<string, string> = {}, raw?: string) {
  return new Request(`${SITE_ORIGIN}/api/contact`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      origin: SITE_ORIGIN,
      'x-forwarded-for': '203.0.113.10',
      ...headers,
    },
    body: raw ?? JSON.stringify(body),
  });
}

/** Simulateur Brevo : renvoie 201 + messageId, comme l'API transactionnelle. */
export function mockBrevo(fetchMock: ReturnType<typeof vi.fn>) {
  fetchMock.mockResolvedValue(new Response(JSON.stringify({ messageId: '<test@example.test>' }), { status: 201 }));
}

Règles du banc :

  • fetch est remplacé par vi.stubGlobal('fetch', fetchMock) ; tout appel à une URL autre que https://api.brevo.com/v3/smtp/email fait échouer le test.
  • La sortie standard est capturée par vi.spyOn(process.stdout, 'write') pour vérifier les journaux (§9.7).
  • Les horloges sont simulées (vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })) dans tous les tests touchant au délai de 500 ms, au délai d'expiration de 8 s ou aux fenêtres de limitation.
  • Chaque test choisit une adresse X-Forwarded-For unique (une seule valeur, donc identique en lecture « dernière entrée ») ou recharge le module pour isoler le limiteur.
  • L'accusé de réception part après la réponse 200 (§9.4.3) : tout test qui vérifie l'accusé, son journal contact.ack ou le nombre total d'appels à Brevo appelle d'abord await flushAfter(). La réponse 200 est vérifiée avant ce vidage pour prouver que l'accusé ne la retarde pas.
  • Les tests de production (APP_ENV=production) positionnent EMAIL_DRY_RUN=false, BREVO_API_KEY, LOG_SALT et EMAIL_FROM_ADDRESS de test.

15.4.2 Matrice POST /api/contact #

Sauf les lignes I-ORD-*, chaque scénario ne viole qu'une seule condition ; toutes les autres sont valides, afin que le résultat ne dépende pas de l'ordre interne des contrôles. L'ordre normatif des contrôles est celui de §9.4.3 : méthode, type de contenu, origine et Sec-Fetch-Site, taille, JSON, limitation de débit, spam, validation, notification, réponse 200, puis accusé de réception ; les lignes I-ORD-* le vérifient.

ID Scénario Statut error.code / corps Vérifications complémentaires
I-200-01 Requête valide, Brevo répond 201 200 {ok:true, data:{requestId}}, requestId UUID v4 1 appel à Brevo à la réponse (notification), le 2ᵉ (accusé) après flushAfter() ; URL exacte https://api.brevo.com/v3/smtp/email ; méthode POST ; en-têtes api-key, content-type: application/json, accept: application/json
I-200-02 Notification 200 — to = CONTACT_TO_EMAIL ; replyTo.email = e-mail du visiteur ; sujet conforme à U-EML-06 ; HTML échappé
I-200-03 Accusé (après flushAfter()) 200 — Destinataire = e-mail du visiteur ; aucune valeur canari saisie par le visiteur dans le corps de l'accusé
I-200-04 SEND_ACKNOWLEDGEMENT=false 200 — 1 seul appel à Brevo
I-200-05 Échec de l'accusé (Brevo : 201 sur la notification, 500 sur l'accusé) 200 ok:true Après flushAfter() : journal contact.ack avec outcome:"ack_failed" ; la réponse 200 était déjà renvoyée
I-200-06 Mode à blanc EMAIL_DRY_RUN=true, BREVO_API_KEY absent 200 ok:true fetch jamais appelé ; journal contenant uniquement des métadonnées (jamais le corps)
I-200-07 Content-Type: application/json; charset=utf-8 200 — Paramètre de type de contenu toléré
I-200-08 E-mail en majuscules 200 — Notification envoyée à l'adresse normalisée en minuscules comme replyTo
I-200-09 APP_ENV=production, le système de fichiers ne contient ni content-validation.json ni registre de licences ni docs/ (simulé) 200 ok:true Les gardes de lecture de fichiers ne s'exécutent jamais au runtime ; aucune lecture de fichier pendant la requête
I-SPAM-01 Honeypot website:'bot' 200 {ok:true, data:{requestId}} indiscernable d'un succès Aucun appel Brevo ; journal outcome:"spam_dropped"
I-SPAM-02 startedAt = Date.now() − 2999 200 idem idem
I-SPAM-03 startedAt plus ancien que 24 h 200 idem idem
I-SPAM-04 Spam silencieux ne consomme pas de créneau supplémentaire d'e-mail 200 — Aucun e-mail ; comportement de comptage conforme à §9.6
I-400-01 JSON mal formé ({"name":) 400 INVALID_JSON Message français ; requestId présent
I-400-02 Corps vide 400 INVALID_JSON idem
I-400-03 Corps contenant des octets UTF-8 invalides (0xC3 0x28) 400 INVALID_JSON idem ; aucun message d'analyseur renvoyé ni journalisé
I-400-04 JSON valide dont la valeur racine n'est pas un objet : [], "x", 42, null 400 INVALID_JSON idem
I-403-01 Origin absent 403 FORBIDDEN_ORIGIN Aucun appel Brevo
I-403-02 Origin: https://evil.example.invalid 403 FORBIDDEN_ORIGIN idem
I-403-03 Origin avec un port ou un sous-domaine différent (https://www.site.example.test, https://site.example.test:8443) 403 FORBIDDEN_ORIGIN Comparaison stricte d'origine
I-403-04 Origin: null 403 FORBIDDEN_ORIGIN idem
I-403-05 APP_ENV=development, Origin: http://localhost:3000 200 — Autorisé uniquement en développement
I-403-06 APP_ENV=staging, Origin: http://localhost:3000 403 FORBIDDEN_ORIGIN Non autorisé hors développement
I-403-07 Origin correct mais Sec-Fetch-Site: cross-site (puis same-site) 403 FORBIDDEN_ORIGIN Aucun appel Brevo ; Sec-Fetch-Site: same-origin et absence de l'en-tête sont acceptés
I-405-01 GET, PUT, PATCH, DELETE, OPTIONS sur /api/contact 405 METHOD_NOT_ALLOWED En-tête Allow: POST ; enveloppe d'erreur §9.4 ; aucun en-tête Access-Control-* dans la réponse
I-413-01 Content-Length: 16385 413 PAYLOAD_TOO_LARGE Rejet avant lecture du corps
I-413-02 Corps en flux sans Content-Length, dépassant 16 384 octets 413 PAYLOAD_TOO_LARGE La lecture est interrompue à la limite
I-413-03 Corps de exactement 16 384 octets ≠ 413 — Atteint la validation (200 ou 422 selon le contenu)
I-415-01 Content-Type: text/plain 415 UNSUPPORTED_MEDIA_TYPE idem
I-415-02 Content-Type absent 415 UNSUPPORTED_MEDIA_TYPE idem
I-415-03 Content-Type: application/x-www-form-urlencoded 415 UNSUPPORTED_MEDIA_TYPE idem
I-422-01 Chaque règle de U-CTC-01 à U-CTC-17 envoyée une à une 422 VALIDATION_ERROR error.fields contient la clé du champ fautif avec un message français ; la valeur saisie n'est jamais renvoyée ; aucun appel Brevo
I-422-02 Plusieurs champs invalides 422 VALIDATION_ERROR fields contient toutes les clés fautives
I-422-03 consent:false 422 VALIDATION_ERROR fields.consent présent
I-429-01 5 requêtes valides puis une 6ᵉ depuis la même IP 429 RATE_LIMITED En-tête Retry-After entier ≥ 1 (≤ 600) ; message français mentionnant d'attendre ; aucun e-mail pour la 6ᵉ
I-429-02 La 6ᵉ requête d'une autre IP 200 — Isolation par IP
I-429-03 TRUST_PROXY=false : 6 requêtes avec 6 X-Forwarded-For différents 429 à la 6ᵉ RATE_LIMITED En-tête ignoré ; tous dans la même clé
I-429-04 Après 10 min + 1 s (horloge avancée) 200 — Fenêtre libérée
I-429-05 30 requêtes en 10 min depuis la même IP (dont des rejets de validation 422 et du spam, qui consomment des tentatives mais pas de créneau accepté), puis la 31ᵉ 429 RATE_LIMITED Règle attempts_10m ; Retry-After entier ≥ 1 ; aucun e-mail pour la 31ᵉ
I-429-06 200 soumissions acceptées en 1 h réparties sur 40 IP, puis la 201ᵉ depuis une 41ᵉ IP 429 RATE_LIMITED Règle global_1h ; Retry-After = délai de libération de la plus ancienne ; message français proposant WhatsApp
I-502-01 Brevo répond 500 à chaque appel de la notification 502 EMAIL_DELIVERY_FAILED 2 appels (essai + 1 reprise après 500 ms) ; aucun accusé envoyé ; message français proposant WhatsApp ou e-mail
I-502-02 Brevo répond 401 (clé invalide) 502 EMAIL_DELIVERY_FAILED 1 seul appel (pas de reprise sur 4xx) ; la clé n'apparaît ni dans la réponse ni dans les journaux
I-502-03 Brevo répond 400 502 EMAIL_DELIVERY_FAILED 1 seul appel
I-502-04 fetch rejette (TypeError: fetch failed), puis réussit 200 — 2 appels pour la notification : erreur réseau reprise une fois
I-502-05 fetch ne se résout jamais 502 EMAIL_DELIVERY_FAILED Abandon à 8 000 ms (AbortController) ; une expiration est traitée comme une erreur réseau : une reprise après 500 ms, soit 2 tentatives et un total de 16 500 ms de temps simulé
I-502-06 Brevo répond 500 puis 201 200 — Reprise réussie
I-502-07 Remboursement après échec : Brevo répond 500 deux fois (502), puis 201 pour la requête suivante avec le même corps 502 puis 200 EMAIL_DELIVERY_FAILED puis ok:true Après le 502 la requête suivante n'est ni limitée ni dédupliquée : les créneaux réservés sont remboursés et l'empreinte de déduplication est supprimée ; 5 requêtes valides consécutives après un 502 sont toutes acceptées
I-DUP-01 Deux corps identiques envoyés à 5 s d'écart 200 puis 200 — Un seul appel de notification à Brevo ; le second requestId est identique au premier ; journal outcome:"duplicate_suppressed" ; aucun créneau supplémentaire consommé
I-500-01 Une exception inattendue est levée dans le traitement (modification du module qui fournit l'EmailProvider de §9.5 pour lever une Error) 500 INTERNAL_ERROR Message générique français ; aucune trace de pile ni message technique dans le corps ; journal level:"error"
I-ENV-01 Tout statut d'erreur — — Content-Type: application/json ; Cache-Control: no-store ; requestId identique à celui du journal correspondant
I-ORD-01 Content-Type: text/plain et Origin absent 415 UNSUPPORTED_MEDIA_TYPE Le type de contenu est contrôlé avant l'origine ; aucun appel Brevo
I-ORD-02 Corps de 20 Ko (JSON invalide) sans Origin 403 FORBIDDEN_ORIGIN L'origine est contrôlée avant la taille et avant l'analyse JSON : le corps n'est pas lu
I-ORD-03 Origin correct, corps de 20 Ko (JSON invalide) 413 PAYLOAD_TOO_LARGE La taille est contrôlée avant l'analyse JSON (jamais 400)
I-ACK-01 3 soumissions valides (corps distincts) avec la même adresse e-mail en 24 h, après flushAfter() 200 × 3 — 3 notifications mais 2 accusés seulement ; journal ack_suppressed pour le 3ᵉ ; la réponse reste 200
I-ACK-02 6 soumissions valides depuis la même IP avec 6 adresses distinctes réparties sur plus de 10 min (24 h) 200 × 6 — 5 accusés seulement (ack_ip_24h) ; le 6ᵉ est ack_suppressed ; notification et réponse 200 inchangées
I-ACK-03 31 soumissions valides en 1 h depuis des IP et des adresses distinctes 200 × 31 — 30 accusés seulement (ack_global_1h) ; le 31ᵉ est ack_suppressed ; notification et réponse 200 inchangées

15.4.3 Journalisation et absence de données personnelles #

Un test canari soumet une requête valide avec les valeurs suivantes : nom CanariNomZ91, e-mail canari.z91@example.test, téléphone 06 11 11 11 11, message CanariMessageZ91 …, IP 203.0.113.77, et clé d'API canari-cle-z91. Il capture toutes les lignes écrites sur la sortie standard pendant le traitement, puis vérifie :

ID Assertion
I-LOG-01 Aucune ligne ne contient CanariNomZ91, canari.z91@example.test, 06 11 11 11 11, CanariMessageZ91, 203.0.113.77 ni canari-cle-z91
I-LOG-02 Chaque ligne est un JSON valide contenant ts, level, event, requestId, outcome, durationMs
I-LOG-03 Les événements de limitation contiennent ipHash de 12 caractères hexadécimaux, identique entre deux requêtes de même IP et différent pour deux IP différentes
I-LOG-04 La même exigence est rejouée pour les chemins 422, 429, 502 et spam
I-LOG-05 Avec LOG_LEVEL=error, aucune ligne de niveau info n'est écrite

15.4.4 /api/health #

ID Scénario Attendu
I-HLT-01 GET /api/health 200 ; {ok:true, data:{status:'ok', version, uptimeSeconds}}
I-HLT-02 version Égal à APP_VERSION quand défini ; chaîne non vide sinon
I-HLT-03 uptimeSeconds Entier ≥ 0, croissant entre deux appels espacés (horloge simulée)
I-HLT-04 Aucun appel réseau fetch n'est jamais appelé
I-HLT-05 En-tête Cache-Control: no-store
I-HLT-06 POST /api/health Statut 405
I-HLT-07 Le corps ne divulgue ni variables d'environnement, ni chemins, ni versions de dépendances Recherche négative de BREVO, LOG_SALT, node_modules, /app
I-HLT-08 HEAD /api/health 200 ; mêmes en-têtes que GET (Cache-Control: no-store, Date) ; corps vide

15.4.5 En-têtes de sécurité (propriétaire : §10.3) #

Les tests importent le constructeur pur buildHeaderRules de src/lib/security/headers.ts, seule source des en-têtes servis par next.config.ts (§10.3) ; ils évaluent les règles pour chaque environnement. Le parcours réel sur la page servie est vérifié par E2E-43.

ID Scénario Attendu
I-HDR-01 Règles de la route /:path* en production Contiennent Content-Security-Policy avec frame-ancestors 'none' et X-Frame-Options: DENY, ainsi que X-Content-Type-Options: nosniff
I-HDR-02 CSP de production Aucun unsafe-eval, aucun * en source, aucun schéma http: ; script-src contient 'unsafe-inline' comme seule concession documentée en §10.3.2
I-HDR-03 HSTS Production : max-age=31536000; includeSubDomains ; staging : max-age=300 sans includeSubDomains ; développement : absent
I-HDR-04 X-Robots-Tag noindex, nofollow sur toutes les routes hors production ; en production uniquement sur /api/:path*

AC-15.4-01 La matrice de §15.4.2 est implémentée intégralement : chaque ID existe comme test nommé, et les neuf statuts 200, 400, 403, 405, 413, 415, 422, 429, 502 (plus 500) sont couverts, y compris le 200 silencieux de spam ; les lignes I-ORD-*, I-DUP-01, I-ACK-01 à I-ACK-03 et I-502-07 couvrent l'ordre des contrôles, la déduplication, les plafonds d'accusé et le remboursement. AC-15.4-02 Les scénarios « 429 » vérifient la présence d'un Retry-After entier et l'absence de tout appel à Brevo. AC-15.4-03 Le test canari I-LOG-01 passe pour les chemins 200, 422, 429, 502 et spam. AC-15.4-04 Les tests de reprise et d'expiration utilisent des horloges simulées et s'exécutent en moins de 1 s réelle chacun. AC-15.4-05 Aucun test d'intégration n'appelle le réseau réel ; un fetch vers une autre URL que celle de Brevo fait échouer la suite.

15.5 Tests de composants #

Environnement jsdom avec user-event. Un utilitaire renderWithProviders() enveloppe chaque composant dans MotionConfig reducedMotion="always" (voir §8) pour que les transitions soient instantanées et déterministes ; un second jeu de tests, marqué @motion, rend sans cette option et ne vérifie que l'absence d'erreur. IntersectionObserver, matchMedia et scrollIntoView sont simulés dans tests/fixtures/setup-dom.ts (§12.8.5).

15.5.1 Boussole de clarté (§6.4) #

ID Cas Attendu
C-CMP-01 Rendu initial Titre « Boussole de clarté » ; 4 onglets (role="tab", ids methode-tab-<id>) dans l'ordre Écouter, Explorer, Clarifier, Décider, issus de method.ts ; une étape active par défaut conformément à §6.4 ; les 4 panneaux (role="tabpanel", ids methode-panel-<id>) sont présents dans le DOM, un seul est exposé aux technologies d'assistance (les 3 autres sont inert et masqués par visibility: hidden)
C-CMP-02 Clic sur « Clarifier » L'étape devient active : aria-selected="true" ; le panneau methode-panel-clarifier est exposé et affiche le texte de l'étape ; les autres panneaux sont inert
C-CMP-03 Touche Tab Un seul arrêt de tabulation pour la liste d'onglets (tabulation itinérante) ; le Tab suivant atteint le panneau
C-CMP-04 Flèche droite / gauche (et bas / haut) Le focus se déplace à l'étape suivante / précédente avec rebouclage et l'onglet est activé immédiatement (activation automatique, sans Entrée)
C-CMP-05 Home / End Première / dernière étape
C-CMP-06 Entrée et Espace sur une étape Active l'étape sans défilement de la page pour Espace
C-CMP-07 Survol à la souris ((hover: hover) simulé) Le panneau de l'étape survolée s'affiche ; la sortie du survol applique la règle de §6.4 ; aucun décalage de mise en page
C-CMP-08 Appui tactile (pointer: coarse simulé, événement pointerdown de type touch) Active l'étape ; le survol n'est pas requis
C-CMP-09 hashchange vers #methode-explorer (ancre = élément vide de hauteur nulle id="methode-explorer", distinct de l'onglet methode-tab-explorer) L'étape « Explorer » devient active
C-CMP-10 Chargement initial avec location.hash = '#methode-decider' L'étape « Décider » est active dès le premier rendu client
C-CMP-11 Hash inconnu (#methode-inconnu) Ignoré ; étape par défaut ; aucune exception
C-CMP-12 Événement d'analyse Une activation appelle track('method_step_open', {step}) une fois (aucune relance si l'étape déjà active est de nouveau choisie)
C-CMP-13 Mouvement réduit Aucune classe ni style de transition de transformation ; changement de contenu instantané
C-CMP-14 Rôles et noms accessibles Motif ARIA « tabs » de §6.4 : role="tablist" nommé ; chaque onglet a un nom accessible non vide et aria-controls="methode-panel-<id>" ; chaque panneau a aria-labelledby="methode-tab-<id>"
C-CMP-15 Bascule d'étape Aucun remontage : les nœuds des 4 panneaux gardent la même identité DOM avant et après un changement d'étape (la transition ne fait varier que l'opacité et un décalage vertical)

15.5.2 Module d'orientation — groupe radio (§6.6) #

ID Cas Attendu
C-ORI-01 Rendu initial Un fieldset avec legend portant role="radiogroup", nom accessible ; 4 input[type=radio] natifs (rôle radio : Décision bloquée, Fatigue, Repositionnement, Transition) tous non cochés ; aucun résultat affiché ; le balisage est présent dans le rendu serveur (sans exécution de JavaScript)
C-ORI-02 Tab Entre dans le groupe sur le premier radio (aucun sélectionné) ; un seul arrêt de tabulation (comportement natif d'un groupe de radios)
C-ORI-03 Flèche bas / droite Déplace le focus et sélectionne l'option suivante, avec rebouclage (comportement natif d'un groupe de radios)
C-ORI-04 Flèche haut / gauche Option précédente avec rebouclage
C-ORI-05 Espace sur une option focalisée non sélectionnée La sélectionne
C-ORI-06 Sélection de « Fatigue » Le résultat affiche : l'invite de réflexion de fatigue, la liste de 2 à 3 questions, le nom de l'offre suggérée lecture-globale, l'avertissement global verbatim « Cette boussole propose une piste de réflexion. Elle ne constitue ni un diagnostic ni une promesse de résultat. »
C-ORI-07 Zone de résultat aria-live="polite" (ou région nommée) : le contenu est annoncé sans déplacer le focus
C-ORI-08 Changement de sélection Le résultat est remplacé (jamais empilé) ; une seule suggestion visible
C-ORI-09 Mapping Pour chacune des 4 situations, l'offre suggérée est celle de U-CNT-08
C-ORI-10 CTA du résultat Deux CTA : « Écrire sur WhatsApp » (lien WhatsApp calculé côté serveur et reçu en propriété) et « Écrire via le formulaire » ; chacun appelle track('cta_click', {cta:'orientation_contact'}) sans autre propriété ; « Écrire via le formulaire » appelle requestContactPrefill({ offerId, situationId }) (§6.5.4) ; aucune URL ne contient l'id de situation
C-ORI-11 Aucun stockage Après toute interaction, localStorage, sessionStorage et document.cookie sont vides
C-ORI-12 Aucune analyse de la situation track n'est jamais appelé avec une propriété contenant l'id de situation (voir U-AN-05)
C-ORI-14 Défilement Le résultat n'est amené dans la vue (scrollIntoView) que pour une sélection à la souris ou au toucher ; une sélection au clavier ne déclenche aucun défilement
C-ORI-15 Mise en page des choix Les 4 puces ont une hauteur ≥ 56 px (classes calculées) ; à partir de 1024 px elles sont disposées en 2 × 2
C-ORI-13 Texte Aucune promesse de résultat ni vocabulaire médical : le texte rendu ne contient aucun des termes de la liste noire suivante, insensible à la casse : « garanti », « garantie », « guérir », « guérison », « thérapie », « résultat assuré », « résultats assurés », « diagnostic » (hors avertissement verbatim de C-ORI-06)

15.5.3 Carrousel de témoignages (§6.8) #

Le test utilise le jeu testimonials.two (voir §15.10.1).

ID Cas Attendu
C-TST-01 Rendu Conteneur <section aria-roledescription="carrousel"> nommé ; chaque diapositive role="group" aria-roledescription="témoignage" avec nom du type « 1 sur 2 »
C-TST-02 Exposition Toutes les diapositives restent montées dans la même cellule de grille ; à tout instant, exactement une est exposée aux technologies d'assistance, les autres sont inert et visibility: hidden
C-TST-03 Boutons précédent / suivant Ont un nom accessible, une taille de 48 px ; changent la diapositive avec rebouclage (la dernière suit la première, boutons jamais désactivés) ; le focus reste sur le bouton actionné
C-TST-04 Indicateur de position Le texte « k sur N » est toujours cohérent avec la diapositive exposée
C-TST-05 Clavier et pastilles Tab atteint les boutons précédent / suivant puis les pastilles dans l'ordre ; chaque pastille visuelle de 10 px a une zone cliquable de 44 px ; Entrée / Espace activent boutons et pastilles
C-TST-06 Pas de lecture automatique Avec vi.advanceTimersByTime(120_000), la diapositive exposée n'a pas changé ; aucun minuteur répétitif actif
C-TST-07 Glissement tactile (événements pointeur) Un déplacement horizontal supérieur au seuil de §6.8 change de diapositive ; un déplacement vertical ne l'empêche pas de défiler
C-TST-08 Un seul témoignage publiable testimonials-section rend la citation de façon statique : ni testimonial-carousel, ni commande de navigation, ni indicateur ; le témoignage reste lisible
C-TST-09 Zéro témoignage publiable (testimonials.none) Le composant ne rend rien ; la section parente n'est pas montée
C-TST-10 Mouvement réduit Fondu instantané ≤ 0,2 s ou changement immédiat ; aucune translation
C-TST-11 Contenu Chaque diapositive affiche exactement : citation (les guillemets « » sont ajoutés par le composant autour du texte, qui reste inchangé dans les données), nom, contexte (si présent) ; aucune image ni monogramme
C-TST-12 Région d'annonce Avant toute interaction la région d'annonce n'est pas active (aria-live="off") ; après la première interaction elle passe à polite
C-TST-13 Publiable Un témoignage n'est rendu que s'il est publié, avec consentement accordé et citation non vide (au plus 700 caractères, aucune longueur minimale) ; un seul caractère suffit
C-TST-14 Rendu serveur Le balisage produit côté serveur contient toutes les citations publiables : sans JavaScript elles sont listées les unes sous les autres

15.5.4 Formulaire de contact (§6.9, contrat §9.4) #

ID Cas Attendu
C-FRM-01 État initial Tous les champs vides sauf offerId (valeur initiale conforme à §6.9) ; le sélecteur visible et facultatif « Votre situation (facultatif) » est présent ; le champ message est vide ; bouton actif ; champ honeypot website présent dans le DOM, masqué visuellement et exclu de l'ordre de tabulation (tabindex="-1", autocomplete="off", aria-hidden) ; aucune étiquette visible pour lui
C-FRM-02 Étiquettes Chaque champ a une <label> associée ; les champs obligatoires sont signalés à la fois visuellement et par aria-required="true"
C-FRM-03 Soumission avec champs vides Aucun fetch ; une liste d'erreurs contact-error-summary est rendue et reçoit le focus ; chaque lien du résumé place le focus sur le champ correspondant ; chaque champ fautif a aria-invalid="true" et un message relié par aria-describedby
C-FRM-04 Règles de champ Les bornes de U-CTC-01 à U-CTC-15 produisent la même issue côté client (le composant utilise contactRequestSchema partagé)
C-FRM-05 Correction d'un champ L'erreur disparaît dès que la valeur devient valide ; l'annonce vocale ne se répète pas inutilement ; la validation d'un champ à la perte du focus n'a lieu que si sa valeur a été modifiée
C-FRM-06 Soumission valide Un seul fetch vers /api/contact : méthode POST, en-tête content-type: application/json, corps contenant startedAt (nombre) et website: '' ; consent: true
C-FRM-07 Pendant l'envoi Le formulaire porte aria-busy="true" ; les champs texte sont en readOnly (jamais fieldset disabled, le focus n'est pas perdu) ; le bouton porte aria-disabled="true" et le libellé de chargement de §7 ; une seconde activation du bouton n'émet aucune seconde requête
C-FRM-08 Réponse 200 Message de succès (contact-success) dans une région role="status" ; le formulaire est remplacé ou vidé selon §6.9 ; track('contact_submit', {result:'success'}) ; le focus est déplacé sur le message de succès
C-FRM-09 Réponse 422 avec fields Les messages serveur sont affichés sous les champs correspondants ; le résumé d'erreurs reçoit le focus
C-FRM-10 Réponse 429 avec Retry-After: 540 Message d'attente en français indiquant un délai exprimé en minutes (9 minutes) ; bouton réactivé ; les données saisies sont conservées
C-FRM-11 Réponse 502 Message d'échec et bloc de repli contact-fallback proposant le lien WhatsApp et l'adresse e-mail ; données conservées ; le résumé d'erreurs reçoit le focus ; track('contact_submit', {result:'error'})
C-FRM-12 Rejet réseau (fetch rejette) Même comportement que 502
C-FRM-13 Réponse 500, 415, 400, 405 Message générique d'erreur et repli ; aucune clé technique affichée ; l'issue de soumission transporte le code d'erreur de l'enveloppe (§9.4.4)
C-FRM-13b Réponse 413 (PAYLOAD_TOO_LARGE) Message invitant à raccourcir le message ; le focus se place sur le champ message ; aucun lien de repli proposé
C-FRM-13c Réponse 403 (FORBIDDEN_ORIGIN) Message d'erreur et repli ; aucun bouton de nouvel essai
C-FRM-22 Expiration côté client fetch ne se résout jamais : aucune interruption avant 30 000 ms de temps simulé ; à 30 000 ms, même comportement que 502
C-FRM-14 Réponse non JSON (page d'erreur de proxy) Traitée comme une erreur réseau ; aucune exception non gérée
C-FRM-15 Préremplissage par ?offre=<id> valide au chargement (lecture unique au montage) Le sélecteur d'offre est positionné, puis history.replaceState retire le paramètre de l'URL ; offre inconnu : valeur par défaut ; le champ message reste vide
C-FRM-16 Événement contact:prefill avec { offerId, situationId } valides Le sélecteur d'offre et le sélecteur visible « Votre situation (facultatif) » sont positionnés et la valeur de situation est transmise dans la requête ; valeurs inconnues ignorées ; le champ message reste vide ; aucune URL ne contient la situation
C-FRM-17 Consentement Case non cochée par défaut ; la case est obligatoire ; son libellé renvoie à la politique de confidentialité par un lien
C-FRM-18 startedAt Fixé au premier rendu ou au premier focus ; ne change pas à chaque frappe
C-FRM-19 Données saisies Ne sont ni écrites dans localStorage/sessionStorage/cookies, ni transmises à track()
C-FRM-20 Compteur de caractères (si prévu en §6.9) Annonce non bruyante (pas d'annonce à chaque frappe)
C-FRM-21 Calibrage d'horloge (§9.4.8) calibrateClock corrige un décalage de +4 s entre l'horloge du navigateur et l'en-tête Date de la réponse à HEAD /api/health, ignore un écart absolu supérieur à 24 h et n'émet qu'un seul HEAD /api/health
C-FRM-23 Champ message Jamais prérempli, quel que soit le préremplissage reçu (?offre=, contact:prefill)

15.5.5 Autres composants #

ID Composant Cas
C-OFR-01 OfferItem (§6.5, variantes panel et row) Rend titre, description, libellé visible « Tarif » suivi du prix formaté (U-FMT), détails dans un <details> natif, CTA « Écrire sur WhatsApp » avec href de U-WA-06, target="_blank", rel contenant noopener et noreferrer, texte masqué visuellement « (ouvre WhatsApp dans un nouvel onglet) » ; lien « Écrire via le formulaire » dont href vaut /?offre=<id>#contact
C-OFR-02 OfferItem Pour kind:'quote', affiche « Sur devis » et aucun symbole « € »
C-OFR-03 Liste d'offres Une offre mise en avant (panel) puis des lignes éditoriales (row), sans grille de cartes ni hauteurs égales imposées ; mention de TVA (vatMention) rendue sous la liste si renseignée ; non rendue sinon (bandeau de développement en §6.11 pour le cas manquant)
C-OFR-04 Lien « Écrire via le formulaire » Un clic appelle requestContactPrefill({ offerId }) ; sans JavaScript le lien href="/?offre=<id>#contact" reste fonctionnel
C-NAV-01 Menu mobile (§6.1) Panneau role="dialog" aria-modal="true" d'identifiant menu-mobile ; ouverture : aria-expanded="true" sur le bouton, html[data-scroll-locked] posé, focus déplacé dans le panneau ; Tab puis Shift+Tab bouclent à l'intérieur ; Échap ferme, retire data-scroll-locked et rend le focus au bouton ; un clic hors du contenu du panneau ne le ferme pas
C-NAV-02 Lien Témoignages Absent quand hasPublishableTestimonials === false, présent sinon
C-NAV-03 Section active Avec IntersectionObserver simulé, la section signalée devient le lien courant (aria-current="location") ; un seul lien courant à la fois
C-NAV-04 États du header (§6.1) État top par défaut ; scrolled dès 24 px de défilement, retour à top sous 8 px ; solid en permanence sur les pages légales ; le header n'est jamais masqué quelle que soit la direction du défilement
C-ERR-01 Page d'erreur (error.tsx, §6.11) Un seul titre de niveau 1 ; bouton « Réessayer » appelant reset() ; lien d'accueil ; focus placé sur le titre ; aucun détail technique affiché
C-ERR-02 Page 404 (not-found.tsx, §6.11) Un seul titre de niveau 1 ; lien vers / ; texte français
C-BTN-01 Boutons (§5) Variantes primaire et secondaire : état focus-visible avec anneau de focus ; état disabled non activable ; taille cible ≥ 44 × 44 px en classes calculées pour les CTA principaux

AC-15.5-01 Chaque ID de §15.5 existe comme test nommé. AC-15.5-02 Les tests de composants n'utilisent aucun setTimeout réel supérieur à 50 ms (horloges simulées). AC-15.5-03 Aucun composant interactif n'est testé uniquement par instantané (snapshot) ; les assertions portent sur les rôles, noms accessibles et états. AC-15.5-04 La suite de composants s'exécute en moins de 45 s.

15.6 Tests de bout en bout (Playwright) #

15.6.1 Matrice des projets #

Projet Moteur / appareil Rôle Étiquettes exécutées
chromium-desktop Chromium, 1440 × 900 Référence : tous les parcours, accessibilité, visuel, performance de mise en page toutes
firefox-desktop Firefox, 1440 × 900 Compatibilité moteur Gecko @smoke, @cross
webkit-desktop WebKit, 1440 × 900 Compatibilité moteur WebKit (Safari bureau) @smoke, @cross
mobile-chrome Émulation Pixel 7 Tactile, viewport étroit, menu mobile, effets simplifiés toutes sauf @desktop-only, @visual
mobile-safari Émulation iPhone 14 WebKit mobile, comportement du viewport iOS @smoke, @cross, @mobile

Définition des étiquettes : @smoke = chargement des routes, navigation par ancres, soumission réussie du formulaire ; @cross = parcours dont le comportement dépend du moteur (focus, inert, scroll-margin, clip-path, formulaires) ; @mobile = parcours propres au tactile ; @desktop-only = parcours exigeant un pointeur précis (survol, parallaxe) ; @a11y = scans axe ; @visual = captures.

Chaque test indique ses projets via ces étiquettes dans son titre, par exemple test('E2E-05 menu mobile piège le focus @mobile @cross', …).

15.6.2 Variantes de build servies #

Les parcours E2E s'exécutent dans Docker Compose (profil e2e, §14.4), contre deux services construits depuis le même Dockerfile (§14.2) et joignables par le réseau interne :

Service Cible Dockerfile URL (= SITE_URL figé au build) Contenu Utilisé pour
web-e2e runner http://web-e2e:3000 Contenu livré tel quel : témoignages non publiables, légal non renseigné, actifs de développement, ANALYTICS_PROVIDER=none Tous les parcours sauf ceux marqués [fixtures]
web-e2e-fixtures runner-fixtures http://web-e2e-fixtures:3000 Superposition de test : deux témoignages publiables (fictifs), légal complet (fictif), ANALYTICS_PROVIDER=umami avec une URL de script fictive https://umami.example.test/script.js interceptée par page.route Parcours marqués [fixtures] : carrousel, lien « Témoignages », mention TVA, pages légales renseignées, mesure d'audience (E2E-44)

Le service e2e (lanceur Playwright) reçoit PLAYWRIGHT_BASE_URL=http://web-e2e:3000 et PLAYWRIGHT_FIXTURES_URL=http://web-e2e-fixtures:3000, rejoint le même réseau et attend le bilan de santé des deux serveurs avant de démarrer. Les deux serveurs sont construits avec APP_ENV=staging et ne sont jamais publiés ; la cible runner-fixtures n'est référencée que par le profil e2e du fichier Compose et par aucun flux de livraison (§15.10.3).

15.6.3 Aides communes #

// tests/e2e/helpers/geometry.ts
import type { Page, Locator } from '@playwright/test';

export type Rect = { x: number; y: number; width: number; height: number };

export const VIEWPORTS = [
  { name: '320',  width: 320,  height: 640 },
  { name: '390',  width: 390,  height: 844 },
  { name: '768',  width: 768,  height: 1024 },
  { name: '834',  width: 834,  height: 1194 },
  { name: '1024', width: 1024, height: 768 },
  { name: '1280', width: 1280, height: 720 },
  { name: '1440', width: 1440, height: 900 },
  { name: '1920', width: 1920, height: 1080 },
  { name: '844-paysage', width: 844, height: 390 },
] as const;

/** Rectangles réels des lignes de texte (pas la boîte du bloc), pour éviter les faux positifs. */
export async function textLineRects(locator: Locator): Promise<Rect[]> {
  return locator.evaluate((el) => {
    const range = document.createRange();
    range.selectNodeContents(el);
    return Array.from(range.getClientRects()).map((r) => ({ x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height }));
  });
}

export async function boxOf(locator: Locator): Promise<Rect> {
  const b = await locator.evaluate((el) => {
    const r = el.getBoundingClientRect();
    return { x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height };
  });
  return b;
}

export function overlapArea(a: Rect, b: Rect): number {
  const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
  return w > 0 && h > 0 ? w * h : 0;
}

/** Faire défiler toute la page pour déclencher les révélations paresseuses avant de mesurer. */
export async function scrollThroughPage(page: Page) {
  await page.evaluate(async () => {
    const step = Math.max(200, Math.floor(window.innerHeight * 0.8));
    for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
      window.scrollTo(0, y);
      await new Promise((r) => setTimeout(r, 60));
    }
    window.scrollTo(0, 0);
  });
}
// tests/e2e/helpers/contact.ts
import { expect, type Page } from '@playwright/test';

let counter = 0;
/** Adresse IP de documentation unique par test (bloc 203.0.113.0/24) : isole le limiteur de débit. */
export function uniqueIp(workerIndex: number) {
  counter = (counter + 1) % 200;
  return `203.0.113.${((workerIndex * 40 + counter) % 250) + 1}`;
}

/** Le serveur écarte silencieusement toute soumission faite moins de 3 s après l'affichage du formulaire. */
export const HUMAN_DELAY_MS = 3_200;

export async function fillValidForm(page: Page) {
  await page.getByLabel('Nom', { exact: false }).fill('Camille Test');
  await page.getByLabel('E-mail', { exact: false }).fill('camille.test@example.test');
  await page.getByLabel('Message', { exact: false }).fill('Ceci est un message de test suffisamment long pour être valide.');
  await page.getByRole('checkbox').check();
}

Les libellés de champs utilisés ci-dessus suivent §7 ; si §7 en définit d'autres, les tests utilisent les libellés de §7 (correspondance partielle insensible à la casse).

Chaque test E2E qui soumet le formulaire fixe un en-tête X-Forwarded-For unique avec context.setExtraHTTPHeaders({'X-Forwarded-For': uniqueIp(testInfo.workerIndex)}). Le total de soumissions acceptées par exécution complète reste sous 150 (le coupe-circuit global est de 200 par heure). Chaque requête POST /api/contact, acceptée ou non, consomme en outre une tentative du plafond attempts_10m (30 par IP et par 10 minutes) : c'est pourquoi chaque test utilise sa propre IP et jamais plus de 30 requêtes par IP. L'IP retenue par le serveur est la dernière entrée de X-Forwarded-For ; les tests n'envoient qu'une valeur.

15.6.4 Parcours E2E #

ID Parcours Étapes et assertions Projets / étiquettes
E2E-00 Santé des services de test GET /api/health sur PLAYWRIGHT_BASE_URL et sur PLAYWRIGHT_FIXTURES_URL : statut 200, status:'ok' ; les deux services répondent avant le lancement des autres parcours @smoke chromium
E2E-01 CTA du hero → ancres Clic « Prendre rendez-vous » : URL #contact, la section #contact a son bord supérieur entre 0 et (hauteur d'en-tête + 32) px et son titre n'est pas masqué par l'en-tête fixe (WCAG 2.4.11). Clic « Découvrir la méthode » : #methode. Hauteur d'en-tête : 72 px ≥ 1024, 64 px en dessous @smoke tous
E2E-02 Liens de navigation et état actif Pour chaque lien du menu (en mode bureau ou menu mobile) : clic → défilement vers l'ancre de §4.2 ; le lien correspondant porte aria-current="location" ; un seul lien courant à la fois ; défilement manuel jusqu'à chaque section met à jour le lien courant. Le header reste visible en permanence : après chaque défilement vers le bas puis vers le haut, son bord supérieur est à 0 px (il ne se masque jamais). Variante base : lien « Témoignages » absent. Variante fixtures : présent entre « À propos » et « Contact » @smoke tous
E2E-03 CTA « Faire le point » Clic → #contact ; événement d'analyse non émis (fournisseur none) @smoke tous
E2E-04 Logo Sur / : retour en haut (#accueil). Sur une page légale : navigation vers / @cross
E2E-05 Menu mobile et piège de focus Largeur 390 : bouton mobile-menu-button visible ; ouverture : aria-expanded="true", le panneau plein écran role="dialog" aria-modal="true" (menu-mobile) apparaît, le focus entre dans le panneau, l'arrière-plan est inerte (un Tab n'atteint jamais le contenu de la page) ; Tab répété (nombre d'éléments focalisables + 1 fois) revient au premier élément ; Shift+Tab en sens inverse ; Échap ferme et rend le focus au bouton ; un clic hors du contenu du panneau ne le ferme pas ; clic sur un lien ferme le menu et défile vers l'ancre ; défilement de l'arrière-plan verrouillé (html[data-scroll-locked]) tant que le menu est ouvert ; redimensionnement vers 1440 ferme le menu @mobile @cross mobile-chrome, mobile-safari, chromium, webkit
E2E-06 Mode de navigation exclusif À chaque largeur de VIEWPORTS : exactement un des deux modes est visible et opérationnel ; liens en ligne à partir de 1280 px, bouton de menu en dessous (le CTA « Faire le point » reste visible dès 640 px) ; jamais les deux, jamais aucun @cross
E2E-07 Lien d'évitement Premier Tab : focus sur « Aller au contenu », visible ; Entrée : le focus se place sur <main id="contenu"> (ou son premier titre) @smoke tous
E2E-08 Rail du hero → boussole Clic sur « Clarifier » dans hero-rail : URL #methode-clarifier, défilement vers la boussole, étape « Clarifier » active (focus placé sur la section) @cross
E2E-09 Lien profond de méthode Chargement de /#methode-decider : étape « Décider » active, sa section visible sous l'en-tête @cross
E2E-10 Offres (contenu) 4 éléments OfferItem dans l'ordre (le panneau mis en avant puis trois lignes éditoriales), sans grille de cartes ; titres identiques à offers.ts ; libellé « Tarif » visible pour chacun ; prix affichés « 300 € », « 150 € », « 1 000 € », « Sur devis » (avec espaces insécables, comparaison normalisée) ; featured : seule seance-clarification porte l'accentuation (data-featured="true") ; le détail de chaque offre s'ouvre et se ferme dans un <details> natif @smoke tous
E2E-11 Lien WhatsApp d'offre Pour chacune des 4 offres : offer-whatsapp-<id> a href exactement égal à https://wa.me/33667295179?text= + encodeURIComponent(offer.whatsappMessage) (calculé dans le test à partir de src/content/fr/offers.ts), target="_blank", rel contenant noopener et noreferrer, texte masqué « (ouvre WhatsApp dans un nouvel onglet) ». Les requêtes vers https://wa.me/** sont interceptées par context.route (réponse 200) et un clic ouvre un nouvel onglet dont l'URL commence par https://wa.me/33667295179 @smoke chromium, mobile-chrome
E2E-12 Offre → formulaire Clic sur « Écrire via le formulaire » de suivi-transition : arrivée sur #contact, sélecteur d'offre = suivi-transition, champ message vide. Chargement direct de /?offre=suivi-transition#contact (lien utilisable sans JavaScript) : même résultat, puis l'URL ne contient plus offre (/#contact). ?offre=inconnu, ?offre=<script>, ?offre= : ignorés (valeur par défaut non-defini), aucun HTML injecté, aucune erreur console @cross
E2E-13 Orientation → formulaire Choix de « Repositionnement » (souris, puis clavier : les flèches sélectionnent immédiatement) : le résultat s'affiche avec l'offre lecture-globale ; il propose « Écrire sur WhatsApp » et « Écrire via le formulaire » ; clic sur le second : #contact, sélecteur d'offre = lecture-globale, sélecteur « Votre situation (facultatif) » = « Repositionnement », champ message vide ; l'URL ne contient jamais la situation ; la requête interceptée de la soumission contient situationId:"repositionnement" et offerId:"lecture-globale" ; rechargement de la page : aucune sélection conservée @smoke tous
E2E-14 Aucune persistance Variante base (ANALYTICS_PROVIDER=none), après les parcours E2E-13 et E2E-15 : context.cookies() est vide, localStorage et sessionStorage sont vides @cross
E2E-15 Formulaire : succès (mode à blanc) Remplissage valide, attente HUMAN_DELAY_MS, soumission. Avec la réponse retardée de 600 ms (page.route), état de chargement visible (formulaire aria-busy="true", champs en lecture seule, bouton aria-disabled="true") puis contact-success visible dans une région status avec le focus ; le formulaire n'est plus soumettable de nouveau sans action volontaire @smoke tous
E2E-16 Formulaire : validation client Soumission à vide : aucune requête /api/contact émise (compteur de requêtes = 0) ; résumé d'erreurs rendu et focalisé ; chaque lien du résumé place le focus sur son champ ; chaque champ fautif a aria-invalid="true" ; saisie d'un e-mail invalide puis sortie du champ : message ; correction : message supprimé @smoke tous
E2E-17 Formulaire : erreurs serveur (a) page.route répondant 422 avec fields.email : erreur sous le champ e-mail, résumé focalisé ; (b) 502 : message d'échec et contact-fallback avec lien WhatsApp (href commençant par https://wa.me/33667295179) et lien mailto:astheoris.pro@gmail.com ; (c) 500, (d) route.abort('failed') (réseau coupé) : même repli ; (e) 413 : focus sur le champ message, aucun repli ; (f) 403 : aucun bouton de nouvel essai ; données saisies conservées dans tous les cas @cross
E2E-18 Formulaire : limite de débit réelle Avec une IP unique : 5 envois valides via context.request.post (en-têtes Origin = URL de base, X-Forwarded-For), puis un 6ᵉ envoi par l'interface : message de limite en français indiquant un délai d'attente ; réponse 429 avec Retry-After ; aucune perte des champs saisis @cross chromium, webkit
E2E-19 Formulaire : double soumission Double clic rapide sur le bouton : exactement 1 requête /api/contact @cross
E2E-20 Formulaire : accessibilité clavier Parcours complet uniquement au clavier : Tab atteint tous les champs dans l'ordre visuel, saisie, case de consentement cochée à l'Espace, soumission à Entrée sur le bouton ; le champ honeypot n'est jamais focalisable @smoke tous
E2E-21 CTA WhatsApp de la section contact contact-whatsapp : href commençant par https://wa.me/33667295179, nouvel onglet, rel correct ; le numéro affiché est « 06 67 29 51 79 » et le lien tel: éventuel vaut tel:+33667295179 @smoke tous
E2E-22 Témoignages masqués Variante base : aucun élément #temoignages, aucun testimonials-section, aucun lien de navigation ni de pied de page vers #temoignages, aucun texte « Stéphanie » ni « Émilie » dans le DOM ; l'enchaînement de sections reste continu (la section « À propos » est suivie de « Contact ») ; aucun espace vide anormal @smoke tous
E2E-23 Témoignages affichés [fixtures] Variante fixtures : section présente entre « À propos » et « Contact », lien de navigation présent, carrousel (<section aria-roledescription="carrousel">) utilisable (suivant / précédent avec rebouclage, pastilles), un seul témoignage exposé à la fois, région d'annonce silencieuse avant la première interaction, pas de défilement automatique après 15 s d'attente, guillemets « » autour de chaque citation, aucune image de client ; avec javaScriptEnabled:false, les deux citations sont listées @smoke @cross tous
E2E-24 Pied de page site-footer est un élément <footer> ; liens de navigation fonctionnels ; téléphone, e-mail astheoris.pro@gmail.com (mailto:), mention « France — accompagnement à distance » ; la carte flottante footer-cta (FloatingCtaCard) a une position calculée ni fixed ni sticky, chevauche la jonction Contact → Footer de la valeur de --footer-overlap (48, 64 ou 80 px selon la largeur), laisse au moins 64 px libres sous le dernier contenu de #contact, est atteignable au clavier et ne masque aucun lien ni texte à aucune largeur (test d'absence de recouvrement, voir E2E-31) ; ses CTA sont « Écrire sur WhatsApp » (principal) et « Écrire via le formulaire » @smoke tous
E2E-25 Pages légales /mentions-legales et /politique-de-confidentialite : statut 200, un seul <h1>, html[lang="fr"], lien de retour vers / ; variante base : le bandeau legal-dev-banner « Informations légales à fournir par Malka avant mise en production » est visible ; variante fixtures : bandeau absent, valeurs de test affichées et durée de conservation lue dans legal.privacy.retention ; sections de la politique de confidentialité présentes avec les ids responsable, formulaire, accuse-reception, whatsapp, boussole, cookies, droits, reclamation, mise-a-jour ; la section audience est absente en variante base (ANALYTICS_PROVIDER=none) et présente en variante fixtures ; lien de réclamation https://www.cnil.fr/fr/plaintes @smoke tous
E2E-26 Page 404 GET /cette-page-n-existe-pas : statut 404, titre français, lien vers /, balise robots en noindex @smoke tous
E2E-27 Balises SEO et social Titre et description non vides (longueurs de §11) ; la balise de lien d'URL de référence de la page (voir §11) vaut SITE_URL + chemin ; balises Open Graph et Twitter présentes ; public/og/og-default.jpg répond 200 en image/jpeg et charge à 1200 × 630 px (naturalWidth / naturalHeight) ; /sitemap.xml liste exactement les 3 URL de U-SEO-01 (dans tous les environnements) ; /robots.txt référence le plan du site en production et vaut Disallow: / sur les variantes de test (APP_ENV=staging), qui portent aussi meta robots noindex et l'en-tête X-Robots-Tag @smoke chromium
E2E-28 Mouvement réduit Voir §15.6.7 @cross
E2E-29 Aucun défilement horizontal Voir §15.6.5 @smoke tous
E2E-30 Aucun recouvrement du hero Voir §15.6.6 @smoke tous
E2E-31 Aucun élément interactif masqué Pour chaque élément interactif visible de / (liens, boutons, champs, input[type=radio], [role=tab]), après scrollIntoView({block:'center'}), document.elementFromPoint au centre retourne l'élément ou l'un de ses descendants ; aucun calque décoratif ne capte le pointeur (pointer-events effectif) ; à toutes les largeurs de VIEWPORTS @smoke chromium, mobile-chrome
E2E-32 Base sans JavaScript javaScriptEnabled:false : le titre du hero, la description, les boutons et tout le contenu textuel des sections sont présents dans le HTML et visibles (opacité calculée > 0, pas de visibility:hidden) ; les liens d'ancre existent ; les quatre situations d'orientation sont rendues en liste statique (@media (scripting: none)) et les quatre étapes de la méthode sont lisibles ; le bloc de repli du formulaire (WhatsApp, e-mail) est visible @cross chromium, webkit
E2E-33 Aucune requête tierce, aucun cookie Variante base, sur chaque route : toutes les requêtes observées (page.on('request')) ont l'origine de la page ; aucune requête vers fonts.googleapis.com, fonts.gstatic.com, wa.me, umami, brevo, google ; context.cookies() vide à la fin ; en-têtes Set-Cookie absents @smoke chromium, firefox, webkit
E2E-34 En-têtes de sécurité Sur / et /api/health : présence des en-têtes de §10 (CSP, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, X-Frame-Options: DENY) ; la CSP contient object-src 'none', base-uri 'self', frame-ancestors 'none' ; Strict-Transport-Security avec max-age=300 (staging) ; absence de X-Powered-By ; X-Robots-Tag présent (variantes hors production) @smoke chromium
E2E-35 Images Toutes les <img> ont width et height (ou rapport d'aspect imposé) ; seule la couche lointaine du hero est chargée avec priorité (fetchpriority="high") ; pour tout visuel raster servi par /_next/image, une requête Accept: image/avif,image/webp reçoit image/avif ; octets d'images chargés avant le premier défilement, en émulation mobile, ≤ 350 000 octets (somme des corps de réponses image/*) ; aucun appel à /_next/image retournant une erreur @smoke mobile-chrome, chromium
E2E-36 Décalage de mise en page Mesure layout-shift (entrées sans hadRecentInput) cumulée de la navigation jusqu'à la fin du parcours complet de la page : ≤ 0,05 à 390 et à 1440, sur / et sur les deux pages légales @smoke chromium, mobile-chrome
E2E-37 Latence d'interaction (substitut INP) Chromium avec ralentissement CPU ×4 (Emulation.setCPUThrottlingRate) : durée d'événement (PerformanceObserver de type event, seuil 16 ms) pour l'ouverture du menu, l'activation d'une étape de boussole, la sélection d'une situation, le focus d'un champ : maximum ≤ 200 ms @desktop-only chromium
E2E-38 Parcours clavier intégral Depuis le lien d'évitement jusqu'au bouton d'envoi : l'ordre de tabulation suit l'ordre visuel ; chaque arrêt a un indicateur de focus visible (contour ou ombre calculés non nuls, contraste de §5) ; aucun piège de focus hors menu mobile ouvert @smoke chromium, firefox, webkit
E2E-39 Tailles de cibles À 390 : CTA principaux (hero, offres, formulaire, menu) ≥ 44 × 44 px ; tous les éléments interactifs ≥ 24 × 24 px (WCAG 2.5.8), hors liens en ligne dans un paragraphe @mobile
E2E-40 Langue et jeu de caractères html[lang="fr"] sur toutes les routes ; meta charset UTF-8 ; texte visible sans caractères de remplacement (U+FFFD) ; aucun marqueur de texte provisoire (liste en AC-G-15) @smoke chromium
E2E-41 Parallaxe et effets bureau Bureau (≥ 1024 px) avec pointeur précis et sans mouvement réduit : après défilement de 600 px, hero-layer-far a une translation verticale comprise entre 1 et 120 px (matrice calculée), hero-layer-mid une translation inférieure ou égale à 64 px, hero-portrait inférieure ou égale à 24 px, hero-layer-near nulle ; hero-headline ne bouge pas et garde une opacité de 1 ; déplacer la souris sur le hero ne modifie aucune transformation (aucune parallaxe au pointeur) ; aucune propriété de mise en page animée (top, left, width, height) ; les animations d'ambiance (halos, brume, reflets) ont chacune iterations === 1 et une durée ≤ 5 000 ms @desktop-only chromium
E2E-42 Effets simplifiés en mobile mobile-chrome : les couches de parallaxe ont une transformation nulle après défilement ; pas d'animation en boucle infinie (document.getAnimations() sans iterations === Infinity) ; flou de filtre absent @mobile
E2E-43 Politique de sécurité de contenu et incrustation Un écouteur securitypolicyviolation est posé avant toute navigation puis le parcours complet est exécuté (E2E-13 puis E2E-15, plus le menu mobile à 390) : 0 violation sur les deux variantes ; la page / servie porte frame-ancestors 'none' dans la CSP et X-Frame-Options: DENY ; une page de test qui charge / dans une <iframe> n'affiche pas le site (cadre bloqué) @smoke chromium
E2E-44 Mesure d'audience activée [fixtures] Variante fixtures (ANALYTICS_PROVIDER=umami, script fictif intercepté par page.route) : le <script> d'analyse porte data-do-not-track="true", data-auto-track="true", data-exclude-search="true" et data-exclude-hash="true" ; l'origine du script apparaît uniquement dans les directives de CSP nécessaires ; la politique de confidentialité affiche la section audience et le bouton d'opposition ; avant tout clic, localStorage est vide ; un clic sur le bouton écrit umami.disabled (seule écriture de stockage de tout le site) et le script fictif n'émet ensuite plus d'événement ; avec /?offre=lecture-globale#contact puis le choix de la situation « Fatigue », aucune requête vers l'origine du script ne contient fatigue ni lecture-globale @cross chromium

15.6.5 Aucun défilement horizontal (E2E-29) #

Pour chaque entrée de VIEWPORTS (320, 390, 768, 834, 1024, 1280, 1440, 1920 et 844 en paysage), sur /, /mentions-legales, /politique-de-confidentialite et la 404 :

test(`E2E-29 pas de défilement horizontal @smoke ${vp.name}`, async ({ page }) => {
  await page.setViewportSize({ width: vp.width, height: vp.height });
  await page.goto('/');
  await scrollThroughPage(page);
  const m = await page.evaluate(() => ({
    doc: document.documentElement.scrollWidth,
    body: document.body.scrollWidth,
    inner: window.innerWidth,
  }));
  expect(m.doc).toBeLessThanOrEqual(m.inner);
  expect(m.body).toBeLessThanOrEqual(m.inner);
  // Aucun élément visible ne déborde à droite du viewport (hors calques décoratifs masqués par overflow clip)
  const offenders = await page.evaluate(() =>
    Array.from(document.querySelectorAll<HTMLElement>('body *'))
      .filter((el) => el.offsetParent !== null && el.getClientRects().length > 0)
      .filter((el) => !el.closest('[aria-hidden="true"]'))
      .filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1)
      .map((el) => el.tagName + (el.id ? '#' + el.id : '')),
  );
  expect(offenders).toEqual([]);
});

Le contrôle est répété avec le texte agrandi (page.addStyleTag avec html { font-size: 200% }) à 320 px de largeur pour vérifier le reflux (WCAG 1.4.10).

15.6.6 Aucun recouvrement du hero (E2E-30) #

À chaque largeur de VIEWPORTS, scrollY = 0, les polices chargées (document.fonts.ready) et l'animation d'entrée terminée (attente de l'absence d'animation CSS/Web Animations en cours, ou 2,4 s au plus — la séquence d'entrée dure au plus 2,2 s, voir §8) :

test(`E2E-30 hero sans recouvrement @smoke ${vp.name}`, async ({ page }) => {
  await page.setViewportSize({ width: vp.width, height: vp.height });
  await page.goto('/');
  await page.evaluate(() => document.fonts.ready);
  // Seules les animations d'entrée de contenu comptent : les cycles d'ambiance (décoratifs, `aria-hidden`, un seul cycle de 5 s au plus) sont ignorés.
  await page.waitForFunction(() => document.getAnimations()
    .filter((a) => !(a.effect as KeyframeEffect | null)?.target?.closest('[aria-hidden="true"]'))
    .every((a) => a.playState !== 'running'), null, { timeout: 4000 });

  const sets: Record<string, Rect[]> = {
    header:      [await boxOf(page.getByTestId('site-header'))],
    headline:    await textLineRects(page.getByTestId('hero-headline')),
    description: await textLineRects(page.getByTestId('hero-description')),
    ctaPrimary:  [await boxOf(page.getByTestId('hero-cta-primary'))],
    ctaSecondary:[await boxOf(page.getByTestId('hero-cta-secondary'))],
    trust:       [await boxOf(page.getByTestId('hero-trust'))],
    portrait:    [await boxOf(page.getByTestId('hero-portrait'))],
    rail:        [await boxOf(page.getByTestId('hero-rail'))],
  };
  // Dégagement bureau (≥ 1024 px) : le bord droit du bloc de titre + 24 px ne dépasse pas le bord gauche du portrait.
  if (vp.width >= 1024) {
    const headlineRight = Math.max(...sets.headline.map((r) => r.x + r.width));
    expect(headlineRight + 24, `dégagement titre/portrait @${vp.name}`).toBeLessThanOrEqual(sets.portrait[0].x);
  }
  const names = Object.keys(sets);
  for (let i = 0; i < names.length; i++) {
    for (let j = i + 1; j < names.length; j++) {
      for (const a of sets[names[i]]) for (const b of sets[names[j]]) {
        expect(overlapArea(a, b), `${names[i]} × ${names[j]} @${vp.name}`).toBeLessThanOrEqual(1);
      }
    }
  }
});

Règles :

  • Tolérance : 1 px² d'aire commune (arrondis sous-pixel).
  • Le mot géant hero-clarte-word, les calques de paysage (hero-layer-*) et tout élément aria-hidden="true" sont exclus : ils sont décoratifs et superposés par conception (le mot se trouve derrière le portrait).
  • hero-portrait désigne l'élément <img> du détourage lui-même, non son conteneur plein écran ; si le conteneur est utilisé comme hook, le test échoue. Sous 1024 px le portrait se trouve dans le cadre hero-band (hook posé sur le cadre) : aucun texte du hero ne chevauche ce cadre.
  • Le test est répété avec prefers-reduced-motion: reduce (états finaux) pour garantir que la version sans mouvement a la même géométrie.
  • Le rail de méthode est vérifié à chaque largeur : à partir de 1280 px il est placé dans la zone de gouttière droite hors de la grille (capsules aux libellés horizontaux) ; de 1024 à 1279 px et en dessous de 1024 px c'est une rangée horizontale de quatre étapes sous les chiffres de confiance. Il n'est jamais masqué : ses quatre liens restent atteignables à toutes les largeurs.

15.6.7 Mouvement réduit (E2E-28) #

page.emulateMedia({ reducedMotion: 'reduce' }) avant goto. Assertions, sur / à 1440 et à 390 :

ID Assertion
E2E-28a Au bout de 300 ms après goto, le titre du hero, la description, les CTA et les chiffres de confiance ont une opacité calculée de 1
E2E-28b Après défilement de 600 px, les couches hero-layer-* ont une transformation none ou la matrice identité
E2E-28c document.getAnimations() ne contient aucune animation d'ambiance (halo, brume, reflet du fleuve) : elles sont désactivées en mouvement réduit
E2E-28d Aucune animation de transformation ou de flou en cours : getAnimations() ne renvoie aucun effet dont les propriétés animées incluent transform, translate, scale ou filter
E2E-28e L'activation d'une étape de la boussole et le changement de diapositive ([fixtures]) sont instantanés ou en fondu ≤ 0,2 s
E2E-28f Le défilement vers une ancre est instantané (scroll-behavior: auto calculé)
E2E-28g Aucune différence de contenu : le même texte et les mêmes liens sont présents que sans réduction de mouvement

AC-15.6-01 Les parcours E2E-00 à E2E-44 existent comme tests nommés par leur ID ; l'exécution complète des cinq projets passe sans reprise sur une machine propre. AC-15.6-02 La géométrie (E2E-29, E2E-30, E2E-31) est vérifiée aux neuf viewports de VIEWPORTS. AC-15.6-03 Chaque parcours qui soumet le formulaire utilise une IP unique et aucun test ne dépend de l'ordre d'exécution. AC-15.6-04 Aucun test E2E n'effectue d'appel réseau vers un hôte autre que les deux services de test (web-e2e, web-e2e-fixtures) ; wa.me et le script d'analyse fictif sont interceptés. AC-15.6-05 La console du navigateur ne contient ni erreur ni avertissement pendant E2E-01 à E2E-27 (écouteur page.on('console') global dans une fixture Playwright qui fait échouer le test).

15.7 Accessibilité #

15.7.1 Analyses automatiques (axe) #

Configuration commune :

// tests/e2e/a11y/axe.ts
import AxeBuilder from '@axe-core/playwright';
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';

export async function expectNoSeriousViolations(page: Page, label: string) {
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'])
    .analyze();
  const blocking = results.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical');
  expect(blocking, `${label}: ${blocking.map((v) => v.id + ' → ' + v.nodes.map((n) => n.target.join(' ')).join(', ')).join(' | ')}`).toEqual([]);
  // Impacts modérés ou mineurs : consignés dans le rapport de test, jamais ignorés silencieusement
  for (const v of results.violations.filter((v) => !blocking.includes(v))) {
    test.info().annotations.push({ type: 'a11y-warning', description: `${label}: ${v.id} (${v.impact})` });
  }
  return results;
}

Aucune règle n'est désactivée globalement. Une désactivation locale d'une règle sur un sélecteur précis exige une entrée de DECISIONS.md (voir §18.7).

Le seuil bloquant est « zéro violation serious ou critical » ; les impacts moderate et minor sont listés dans le rapport (annotations ci-dessus), jamais ignorés. États et routes analysés (chacun un test @a11y, projet chromium-desktop, aux largeurs 1440 et 390 sauf mention) :

ID Route / état
A11Y-01 / état initial après chargement et fin des animations d'entrée
A11Y-02 / après défilement complet (toutes les sections révélées)
A11Y-03 / avec reducedMotion: 'reduce'
A11Y-04 / à 390 avec menu mobile ouvert
A11Y-05 / avec chacune des 4 étapes de la boussole active (4 analyses)
A11Y-06 / avec la boussole d'orientation : option sélectionnée et résultat affiché (une analyse par situation, 4 analyses)
A11Y-07 / avec le formulaire en erreurs de validation client
A11Y-08 / avec le formulaire en état d'envoi (réponse retardée)
A11Y-09 / avec le formulaire en état de succès
A11Y-10 / avec le formulaire en erreur serveur (502) et bloc de repli affiché
A11Y-11 / avec le formulaire en erreur 429
A11Y-12 / variante fixtures : section Témoignages, première puis deuxième diapositive
A11Y-13 /mentions-legales variante base (bandeau de développement) et variante fixtures
A11Y-14 /politique-de-confidentialite variantes base et fixtures
A11Y-15 Page 404
A11Y-16 Focus : après Tab sur chaque élément interactif du hero, l'indicateur de focus a un contraste ≥ 3:1 (calculé par le test sur la couleur du contour et celle du fond adjacent)
A11Y-17 / à 768 (tablette, un seul passage) : hero recomposé avec son cadre, menu ouvert, carte flottante du pied de page

L'état d'erreur de l'application (error.tsx) ne peut pas être provoqué sur le build de production sans point d'entrée de test, que l'on se refuse d'ajouter : il est couvert par le test de composant C-ERR-01 (structure, rôles, focus) et par la checklist manuelle MAN-14 (§15.7.3).

Les résultats incomplete d'axe pour le contraste de texte posé sur image (hero, pied de page) ne peuvent être calculés automatiquement : ils sont listés dans le rapport et vérifiés manuellement contre le tableau de contrastes de §5 (checklist MAN-09).

15.7.2 Critères WCAG 2.2 AA à vérifier explicitement #

Critère Vérification Où
1.3.1, 4.1.2 Rôles, noms, valeurs axe + C-CMP-14, C-ORI-01, C-TST-01 auto
1.4.3 Contraste du texte, 1.4.11 Contraste des éléments non textuels U-DS-01 + axe + MAN-09 auto + manuel
1.4.4 Redimensionnement du texte (200 %) et 1.4.10 Reflux (320 px / 400 %) E2E-29 (reflux) + MAN-05, MAN-06 auto + manuel
1.4.12 Espacement du texte Test E2E injectant line-height:1.5; letter-spacing:.12em; word-spacing:.16em; p{margin-bottom:2em} : aucun texte tronqué ni recouvert (E2E-30 rejoué avec ces styles) auto
1.4.13 Contenu au survol ou au focus Le panneau de survol de la boussole est persistant, ignorable (Échap) et ne masque pas le contenu requis (C-CMP-07) auto
2.1.1, 2.1.2 Clavier, pas de piège E2E-05, E2E-20, E2E-38 auto
2.2.2 Pause, arrêt, masquage Aucun contenu en mouvement > 5 s sans commande : chaque animation d'ambiance s'exécute en un seul cycle de 5 s au plus par entrée dans la fenêtre, avec un état de repos symétrique, est en pause hors écran, désactivée sur mobile et en mouvement réduit, et purement décorative (E2E-41, E2E-42, E2E-28c) ; pas de lecture automatique (C-TST-06) auto + manuel
2.3.3 / 2.3.1 Aucun clignotement > 3 par seconde (revue manuelle MAN-10) manuel
2.4.1 Contourner des blocs E2E-07 auto
2.4.3, 2.4.7 Ordre et visibilité du focus E2E-38, A11Y-16 auto
2.4.11 Focus non masqué (minimum) Pour chaque élément focalisable de /, après focus(), son rectangle n'est pas entièrement recouvert par l'en-tête fixe (scroll-margin-top effective ≥ hauteur d'en-tête + 16 px) auto (E2E-01, E2E-38)
2.5.1, 2.5.7 Gestes et déplacements Toute fonction au glissement (carrousel) a une alternative à bouton (C-TST-03) auto
2.5.3 Étiquette dans le nom Le nom accessible de chaque bouton et lien contient son texte visible auto (règle axe label-content-name-mismatch)
2.5.8 Taille de cible (minimum) E2E-39 auto
3.2.6 Aide cohérente Les liens de contact (téléphone, WhatsApp, e-mail) apparaissent dans le même ordre relatif dans le contact et le pied de page manuel (MAN-11)
3.3.1, 3.3.3 Identification et suggestion d'erreur C-FRM-03, C-FRM-09 auto
3.3.2 Étiquettes ou instructions C-FRM-02 auto
3.3.7 Saisie redondante Le formulaire ne redemande aucune information déjà saisie ; l'offre et la situation sont préremplies depuis les modules (E2E-12, E2E-13) auto
3.3.8 Authentification accessible Sans objet (aucune authentification) n.a.
4.1.3 Messages d'état C-FRM-08 (région status), C-ORI-07 auto

15.7.3 Checklist manuelle #

Elle est exécutée une fois par version candidate (jalon M7, puis avant M8) par la personne qui assume la recette, puis consignée dans docs/a11y-report.md (date, outils, résultats, anomalies, corrections). Pour passer, toutes les lignes sont « conforme » ou documentées avec correction planifiée sans gravité bloquante.

ID Contrôle Outil / méthode Critère de réussite
MAN-01 Lecture complète de / NVDA + Firefox (Windows) Ordre de lecture logique ; chaque section annoncée avec son titre ; aucune lecture de texte décoratif (mot CLARTÉ, couches) ; liens WhatsApp annoncés avec la mention « (ouvre WhatsApp dans un nouvel onglet) »
MAN-02 Navigation par titres, repères et listes NVDA (touches H, D, K, F) Un seul <h1> ; hiérarchie sans saut ; repères banner, main, contentinfo, navigation présents et nommés
MAN-03 Formulaire de contact NVDA + Firefox Libellés lus ; erreurs annoncées et reliées ; état d'envoi, succès et échec annoncés une fois chacun
MAN-04 Boussole de clarté et module d'orientation NVDA + VoiceOver Rôle, état sélectionné et contenu du panneau annoncés correctement ; le résultat de l'orientation est annoncé sans perdre le focus
MAN-05 Zoom 200 % Chromium et Firefox, 1280 px Aucune perte de contenu ni de fonction ; pas de défilement horizontal
MAN-06 Zoom 400 % (équivalent 320 px) Chromium Reflux en une colonne ; aucun contenu tronqué ; menu mobile utilisable
MAN-07 VoiceOver iOS (Safari, iPhone) Balayage et rotor Menu mobile, boussole, orientation, formulaire, carrousel ([fixtures]) utilisables ; liens externes annoncés
MAN-08 Clavier seul, sans souris Chromium, Firefox, Safari bureau Tous les parcours de E2E-38 réalisables ; focus toujours visible et non masqué par l'en-tête fixe
MAN-09 Contraste du texte sur image Pipette ou outil de contraste sur captures du hero et du pied de page, à 390 et 1440 Texte courant ≥ 4,5:1 ; grand texte et éléments d'interface ≥ 3:1 sur la zone la plus claire derrière chaque ligne de texte
MAN-10 Mouvement OS en mode « réduire les animations » puis normal Mode réduit : pas de translation, de parallaxe, de boucle ; mode normal : aucune animation agressive, aucun clignotement
MAN-11 Aide cohérente et cibles tactiles iPhone et Android réels Cibles confortables ; liens de contact cohérents
MAN-12 Contraste élevé et couleurs forcées Windows « Contraste élevé » (Firefox ou Edge) Boutons, champs et focus restent visibles
MAN-13 Texte seul / Lecteur de Safari Mode lecture Le contenu éditorial reste compréhensible
MAN-14 Page d'erreur applicative Provoquer une erreur en développement (composant qui lève une exception) avec le build de développement Titre unique, bouton « Réessayer » fonctionnel, focus placé sur le titre, aucun détail technique

AC-15.7-01 Les 17 analyses A11Y-01 à A11Y-17 passent avec zéro violation grave ou critique. AC-15.7-02 Aucune règle axe n'est désactivée globalement ; chaque exclusion locale est justifiée dans DECISIONS.md. AC-15.7-03 Le rapport docs/a11y-report.md existe, daté, couvre MAN-01 à MAN-14, et ne contient aucune anomalie de gravité bloquante. AC-15.7-04 Le score Lighthouse Accessibilité vaut 100 sur / (voir §15.8).

15.8 Performance #

15.8.1 Lighthouse CI #

Les seuils sont ceux de §11 (propriétaire) ; le fichier suivant les traduit en assertions exécutables. En cas d'écart entre ce fichier et §11, §11 prévaut et le fichier est corrigé.

{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/mentions-legales",
        "http://localhost:3000/politique-de-confidentialite"
      ],
      "numberOfRuns": 5,
      "settings": {
        "formFactor": "mobile",
        "throttlingMethod": "simulate",
        "skipAudits": ["is-crawlable"]
      }
    },
    "assert": {
      "aggregationMethod": "median-run",
      "assertions": {
        "categories:performance":    ["error", { "minScore": 0.9 }],
        "categories:accessibility":  ["error", { "minScore": 1 }],
        "categories:best-practices": ["error", { "minScore": 0.95 }],
        "categories:seo":            ["error", { "minScore": 1 }],
        "largest-contentful-paint":  ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift":   ["error", { "maxNumericValue": 0.05 }],
        "total-blocking-time":       ["error", { "maxNumericValue": 200 }],
        "resource-summary:script:size":      ["error", { "maxNumericValue": 174080 }],
        "resource-summary:stylesheet:size":  ["error", { "maxNumericValue": 35840 }],
        "resource-summary:font:size":        ["error", { "maxNumericValue": 204800 }],
        "resource-summary:image:size":       ["error", { "maxNumericValue": 358400 }],
        "resource-summary:total:size":       ["error", { "maxNumericValue": 921600 }],
        "resource-summary:third-party:count": ["error", { "maxNumericValue": 0 }],
        "dom-size":                  ["error", { "maxNumericValue": 1200 }],
        "uses-responsive-images":    ["error", { "minScore": 1 }],
        "offscreen-images":          ["error", { "minScore": 1 }],
        "font-display":              ["error", { "minScore": 1 }],
        "unsized-images":            ["error", { "minScore": 1 }],
        "uses-text-compression":     ["error", { "minScore": 1 }],
        "target-size":               ["error", { "minScore": 1 }],
        "errors-in-console":         ["error", { "minScore": 1 }]
      }
    },
    "upload": { "target": "filesystem", "outputDir": ".lighthouseci" }
  }
}

Règles :

  • Il existe un seul fichier lighthouserc.json (port 3000 : le service de test est joignable sur localhost:3000 depuis l'hôte, voir §14.4). make lighthouse en dérive les URL à partir de la variable d'outillage LHCI_BASE_URL lorsqu'elle est définie (option --collect.url).
  • Les mesures s'effectuent sur le build de production du conteneur construit avec APP_ENV=staging (cible runner, profil e2e ou make lighthouse), jamais sur le serveur de développement.
  • Les builds de test sont volontairement non indexables (noindex, robots.txt en Disallow: /, X-Robots-Tag, §11.1.7). L'audit is-crawlable est donc retiré du calcul par skipAudits (neutraliser une simple assertion ne suffirait pas : l'audit resterait compté dans le score SEO) et aucune assertion ne le vise. L'indexabilité de la production est contrôlée au jalon M8 par le runbook de §14.11 (curl de robots.txt, absence de X-Robots-Tag et de noindex).
  • Les valeurs des budgets sont celles de §11 (propriétaire des chiffres) ; ce fichier est la traduction exécutable unique, aucune autre copie n'existe.
  • L'émulation mobile avec ralentissement simulé est la référence (l'objectif de §11 vise le mobile). Une passe bureau complémentaire, non bloquante, est conservée en rapport.
  • Le Total Blocking Time (≤ 200 ms) est le substitut de laboratoire de l'INP ; l'INP réel est contrôlé par E2E-37 faute de mesure de terrain (aucun suivi des visiteurs en MVP).
  • Le médian des 5 passes est utilisé pour les assertions. Politique de marge : la porte est 0,9 sur ce médian ; un échec dont le score reste ≥ 0,85 sans modification de code entre deux exécutions est relancé une fois à la main (pas de reprise automatique) et consigné dans docs/perf-report.md.

15.8.2 Budgets complémentaires #

Budget Mesure Seuil Test
JavaScript de premier chargement de / scripts/check-bundle-size.ts : récupère le HTML de /, liste les <script src> et <link rel="modulepreload">, compresse chaque fichier en gzip, additionne ≤ 174 080 octets gzip (170 Ko) pnpm check:bundle
Poids d'images au-dessus de la ligne de flottaison (mobile) E2E-35 ≤ 350 000 octets E2E
Contribution de Motion Le script de budget imprime la taille gzip des segments contenant motion ; aucun import de motion/react en dehors de LazyMotion + composants m.* (règle ESLint no-restricted-imports sur le composant motion complet) Documentation de la valeur dans le rapport ; régression > 10 % entre deux versions signalée pnpm check:bundle
Dépendances au bundle client Aucune importation de zod complet hors schémas partagés ; aucune bibliothèque d'icônes entière (imports nominatifs lucide-react) Règle ESLint pnpm lint
Polices Deux familles variables uniquement (Fraunces, avec sa seconde instance italique chargée sans préchargement, et Manrope), sous-ensembles latin et latin-ext, aucune requête vers un hôte de polices à l'exécution (E2E-33) n.a. E2E
Temps de réponse serveur de / TTFB sur le serveur standalone local ≤ 200 ms (page statique) Test E2E avec performance.getEntriesByType('navigation')

15.8.3 Performance mobile avec images en couches #

Parce que le hero superpose plusieurs images (voir §6.2), un contrôle spécifique est exécuté en mobile-chrome avec ralentissement CPU ×4 et réseau « 4G lente » :

  1. sous 1024 px, aucun des calques de parallaxe n'est chargé ; seul le paysage aplati du cadre (hero-mobile) et le portrait sont utilisés (nombre de requêtes d'images du hero ≤ 2) ;
  2. sous 1024 px le candidat LCP est le <h1> (texte, jamais d'opacité nulle) ; LCP ≤ 2,5 s en laboratoire ;
  3. aucune tâche longue (> 50 ms) pendant le défilement du hero (entrées longtask, tolérance : 3 tâches).

AC-15.8-01 pnpm lighthouse passe avec les seuils de §15.8.1 sur les 3 URL. AC-15.8-02 pnpm check:bundle échoue si le JavaScript de premier chargement dépasse 174 080 octets gzip. AC-15.8-03 Les contrôles de §15.8.3 passent en émulation Pixel 7 avec ralentissement. AC-15.8-04 Les valeurs mesurées (scores, LCP, CLS, TBT, taille JS) sont consignées dans docs/perf-report.md à chaque jalon M3, M7 et M8.

15.9 Régression visuelle #

Décision. La régression visuelle est limitée au hero et au pied de page, car ce sont les compositions où le recouvrement et la hiérarchie sont les plus fragiles. Elle repose sur toHaveScreenshot de Playwright, sans service tiers.

Paramètre Valeur
Projet chromium-desktop uniquement
Environnement Linux uniquement, dans l'image officielle Playwright associée à la version du lockfile ; le test se saute automatiquement (test.skip(process.platform !== 'linux')) ailleurs
Largeurs 390 × 844, 834 × 1194, 1440 × 900
Éléments hero (viewport) et site-footer (élément) : 2 × 3 = 6 captures
État capturé reducedMotion: 'reduce', animations: 'disabled', polices chargées, après défilement complet pour le pied de page, curseur masqué
Tolérance maxDiffPixelRatio: 0.02 et threshold: 0.2
Masques Aucun ; les composants dépendant d'une horloge n'existent pas
Stockage des références tests/e2e/visual/__screenshots__/ versionnées dans Git
Mise à jour pnpm test:visual:update dans l'image Playwright, uniquement par un changement délibéré, avec revue visuelle des captures dans la demande de fusion et entrée DECISIONS.md si le changement n'est pas lié à une tâche de design
Moment de régénération obligatoire Lors de l'intégration des vrais actifs de Malka (portrait, paysages) au jalon M7, puisque les références initiales utilisent les actifs de développement
Blocage Obligatoire sur la branche principale (job visual, §15.11) et sur les demandes de fusion touchant src/components/sections/hero/, src/components/sections/footer/, src/styles/ ou public/images/

AC-15.9-01 Les 6 captures de référence existent et correspondent au rendu actuel dans la tolérance. AC-15.9-02 La mise à jour des références hors de l'image Playwright est interdite (aucune référence générée sur macOS ou Windows n'est versionnée). AC-15.9-03 Après intégration des vrais actifs, les références sont régénérées dans le même jalon et validées visuellement par Malka (voir §15.13).

15.10 Données de test, fixtures et garde-fous de production #

15.10.1 Jeux de fixtures #

Tous les jeux se trouvent sous tests/fixtures/, utilisent des noms et domaines manifestement fictifs, et contiennent le marqueur littéral [FIXTURE] dans tout champ texte qui ressemble à du contenu éditorial.

Fichier Contenu Usage
content/testimonials.none.ts Les deux entrées livrées, non publiables (copie conforme de l'état de production initial) U-TST-08, C-TST-09, E2E-22
content/testimonials.one.ts 1 témoignage publiable : auteur « Auteur Fixture A », citation [FIXTURE] Texte de test, sans valeur éditoriale., consentement granted, grantedOn:'2026-01-15' C-TST-08
content/testimonials.two.ts 2 publiables (« Auteur Fixture A », « Auteur Fixture B ») + 1 non publiable + 1 en attente C-TST-01 à C-TST-07, E2E-23
content/testimonials.invalid-*.ts Une variante invalide par règle de U-TST-02 à U-TST-07 U-TST-xx
content/offers.invalid-*.ts Doublon d'id, prix négatif, message vide U-CNT-04 à U-CNT-06
content/orientation.invalid-*.ts suggestedOfferId inexistant, questions hors bornes U-CNT-07 à U-CNT-10
legal/legal.complete.ts Tous les champs de §9.3 renseignés avec des valeurs fictives ; domaines en .test ; SIRET de test qui ne désigne aucune entreprise réelle U-GRD-03, E2E-25
legal/legal.incomplete.ts Tous les champs vides U-GRD-02, E2E-25

Les auteurs fictifs ne reprennent jamais les noms « Stéphanie » ni « Émilie », afin qu'une fuite éventuelle de fixture soit détectable par le contrôle de E2E-22.

15.10.2 Construction des variantes E2E #

Les deux variantes sont deux cibles du Dockerfile de §14.2, construites par docker compose --profile e2e build web-e2e web-e2e-fixtures e2e ; aucun script de préparation ni copie du dépôt n'existe :

  1. web-e2e : cible runner, contenu livré tel quel ;
  2. web-e2e-fixtures : cible runner-fixtures. Dans l'étape de construction de cette image seulement (jamais dans l'arbre de travail), elle remplace src/content/fr/testimonials.ts par tests/fixtures/content/testimonials.two.ts et src/content/fr/legal.ts par tests/fixtures/legal/legal.complete.ts, puis lance pnpm build ;
  3. chaque service est construit avec APP_ENV=staging et SITE_URL égal à son URL interne (http://web-e2e:3000, http://web-e2e-fixtures:3000) ; la variante fixtures active en plus ANALYTICS_PROVIDER=umami avec des valeurs fictives (UMAMI_SCRIPT_URL=https://umami.example.test/script.js, identifiant de site fictif) pour E2E-44 ; à l'exécution les deux serveurs reçoivent EMAIL_DRY_RUN=true, TRUST_PROXY=true, SEND_ACKNOWLEDGEMENT=true et un LOG_SALT de test ;
  4. le service e2e (cible de lanceur Playwright) lit PLAYWRIGHT_BASE_URL et PLAYWRIGHT_FIXTURES_URL et n'écrit jamais dans src/ ; la cible runner-fixtures n'est référencée que par ce profil.

15.10.3 La production n'utilise jamais de fixtures #

Cinq garde-fous cumulés (les identifiants G-xx de §9.3.7 désignent les gardes de contenu et d'environnement ; les tests de ces garde-fous portent les identifiants GT-xx de §15.10.4) :

# Garde-fou Mécanisme Quand
1 Aucune importation depuis tests/ dans src/ Règle ESLint no-restricted-imports (motifs **/tests/**, **/fixtures/**) pnpm lint
2 Dossier exclu de l'image de production next build ne compile que les modules atteignables depuis src/app, aucun import ne mène à tests/ (garde-fou 1) ; l'image runner ne copie que la sortie standalone, .next/static et public/ build
3 Marqueur textuel Le script scripts/check-no-fixtures.ts (pnpm check:no-fixtures) recherche [FIXTURE], Auteur Fixture et test-fixtures dans le dossier .next/ extrait des images construites depuis la cible runner ; toute occurrence fait échouer la commande. Il n'est jamais exécuté sur une image runner-fixtures (qui contient volontairement ces marqueurs) CI, job docker-build, sur chaque image runner
4 Garde-fou de contenu Le détecteur de textes provisoires de §9.3.6 refuse [FIXTURE] et Auteur Fixture quand APP_ENV=production (U-GRD-05) build
5 Actifs de développement Le build de production échoue si une configuration référence dev-fallback (garde G-02, voir §9.3.7) build

15.10.4 Tests des garde-fous de production #

ID Test Résultat attendu
GT-01 check:guards (appel direct des fonctions de garde de §9.3.7) avec les jeux de fixtures legal.incomplete, legal.complete, une référence à dev-fallback, un marqueur [FIXTURE] Voir U-GRD-01 à U-GRD-05
GT-02 Build négatif : APP_ENV=production pnpm build sur le dépôt tel que livré par l'exécutant tant que Malka n'a pas fourni les éléments de §1.3 Le build échoue (code de sortie ≠ 0) et le message liste les champs légaux manquants, les références dev-fallback et/ou les clés non validées du registre content-validation.json
GT-03 Build positif (à partir du jalon M8, une fois les éléments de Malka intégrés) : même commande Le build réussit ; check:no-fixtures passe ; aucun fichier de public/images/dev-fallback/ n'est référencé
GT-04 Secrets : recherche de BREVO_API_KEY, api-key, LOG_SALT dans .next/static/** 0 occurrence
GT-05 Inspection de l'image (docker inspect, docker run --rm … id) Utilisateur nextjs (uid 1001), pas root

AC-15.10-01 Les cinq garde-fous de §15.10.3 existent et sont exécutés par la CI. AC-15.10-02 GT-02 est automatisé dans la CI (étape attendue en échec tant que M8 n'est pas atteint, puis GT-03 le remplace) ; l'inversion est consignée dans DECISIONS.md au jalon M8. AC-15.10-03 Aucun fichier sous tests/fixtures/ n'est présent dans l'image runner (vérifié par docker run --rm <image runner> sh -c "find /app \\( -path '*/tests/fixtures*' -o -name 'server-only-stub*' \\) -print" : sortie vide). La recherche est limitée à /app pour ne pas rencontrer les dossiers fixtures de node_modules. AC-15.10-04 Les noms « Stéphanie » et « Émilie » n'apparaissent dans aucun fichier de tests/fixtures/.

15.11 Intégration continue et portes de qualité #

La CI exécute les mêmes commandes que le poste de développement (aucune commande propre à la CI). Elle est décrite en §14.15.1 ; le tableau suivant associe chaque tâche de contrôle au job qui la porte. Jobs : quality, unit-integration, e2e (accessibilité comprise), lighthouse, docker-build, security, visual (branche principale seulement), release-gate.

# Tâche Commandes Job (§14.15.1) Bloquant Dépend de
1 Statique pnpm install --frozen-lockfile ; pnpm lint ; pnpm format:check ; pnpm typecheck ; tests d'outillage pnpm vitest run --project unit tests/unit/tooling quality Oui —
2 Tests unitaires, composants, intégration pnpm test:coverage (seuils de §15.1.3) ; l'étape Docker test exécute les trois projets hors tests/unit/tooling unit-integration Oui 1
3 Construction de l'image docker build --target runner avec arguments de §14 (APP_ENV=staging) ; démarrage du conteneur ; état de santé healthy en 60 s ; pnpm check:no-fixtures ; pnpm check:bundle ; GT-02 (build négatif) ; GT-04 ; GT-05 ; recherche des motifs de secrets dans .next/static docker-build Oui 1
4 E2E et accessibilité make test-e2e (cinq projets, deux services de test ; les scans @a11y en font partie) e2e Oui 3
5 Performance pnpm lighthouse lighthouse Oui 3
6 Visuel pnpm test:visual (Linux) visual Oui sur branche principale 3
7 Garde-fous de production pnpm check:guards (GT-01) quality Oui 1
8 Audit des dépendances et secrets pnpm audit --prod --audit-level=high ; ! git grep -nE "xkeysib-[A-Za-z0-9-]{20,}" -- . ':!tests' ; le fichier .dockerignore contient .env* et .git security Oui (échec sur gravité haute ou critique) 1
9 Porte de mise en production Tâches 1 à 8 vertes sur le commit déployé ; GT-03 release-gate Oui (à M8) 2 à 8
10 Nocturne Reprise de 3 à 6 avec 5 passes Lighthouse ; mise à jour de rapports planifié Non (alerte) —

Règles :

  • Une demande de fusion est acceptée uniquement si les tâches 1 à 5 et 7 à 8 sont vertes (la tâche 6 s'ajoute pour les modifications visuelles, voir §15.9).
  • Budget de durée : pipeline complet ≤ 20 min.
  • Reprise automatique : 1 reprise pour E2E uniquement (retries: 1) ; un test qui échoue puis réussit est signalé « instable » dans le rapport et doit être corrigé sous 5 jours ouvrés ; les spécifications d'accessibilité (tests/e2e/a11y/) fixent retries: 0 et Lighthouse n'est jamais relancé automatiquement.
  • Les artefacts (rapport HTML Playwright, traces, rapports Lighthouse, captures en échec) sont conservés 14 jours.
  • Porte de mise en production (M8) : tâches 1 à 8 vertes sur le commit exact déployé, GT-03 vert, checklist manuelle MAN-01 à MAN-14 consignée, envoi réel validé (AC-G-21), et validation de Malka (§15.13).

AC-15.11-01 Les huit jobs quality, unit-integration, e2e, lighthouse, docker-build, security, visual et release-gate existent dans la configuration de CI de §14.15.1 et portent les tâches 1 à 9 du tableau ci-dessus ; les tâches 1 à 5 et 7 à 8 s'exécutent sur chaque demande de fusion. AC-15.11-02 Le pipeline complet s'exécute en 20 minutes ou moins sur une machine à 4 cœurs. AC-15.11-03 Aucune tâche bloquante n'est marquée « autorisée à échouer ».

15.12 Critères d'acceptation consolidés #

15.12.1 Critères d'acceptation par fonctionnalité #

Les critères détaillés d'une fonctionnalité sont portés par les sections indiquées (identifiants AC-<section>-NN). La notation AC-6.2-* désigne l'ensemble des critères de la sous-section 6.2. La colonne « Preuve automatique » renvoie aux tests de la présente section ; « Preuve manuelle » aux lignes de §15.7.3.

# Fonctionnalité Critères propriétaires Preuve automatique Preuve manuelle
1 Hero cinématographique « CLARTÉ » AC-6.2-, AC-5-, AC-8-* E2E-01, E2E-08, E2E-28, E2E-30, E2E-35, E2E-41, E2E-42, A11Y-01 à A11Y-03, visuel MAN-09, MAN-10, validation de Malka
2 Navigation premium AC-6.1-, AC-4- E2E-02 à E2E-07, C-NAV-01 à C-NAV-04, A11Y-04, A11Y-17 MAN-01, MAN-08
3 Philosophie « Entreprise vivante » AC-6.3-* E2E-32, A11Y-02, U-TYP-08 MAN-01
4 Boussole de clarté AC-6.4-* C-CMP-01 à C-CMP-15, E2E-08, E2E-09, A11Y-05 MAN-04
5 Accompagnements AC-6.5-, AC-9.3- U-CNT-02 à U-CNT-06, U-CNT-17, U-FMT-, U-WA-, C-OFR-*, E2E-10 à E2E-12 —
6 Boussole d'orientation AC-6.6-* C-ORI-01 à C-ORI-15, E2E-13, E2E-14, A11Y-06 MAN-04, MAN-07
7 À propos de Malka AC-6.7-* E2E-02, E2E-32, E2E-35, A11Y-02 validation de Malka
8 Témoignages AC-6.8-, AC-9.3- U-TST-, C-TST-, E2E-22, E2E-23, A11Y-12 consentements vérifiés par Malka
9 Contact et prise de rendez-vous AC-6.9-, AC-9.4-, AC-9.5-, AC-9.6-, AC-10-* U-CTC-, U-EML-, U-SP-, U-RL-, I-, C-FRM-, E2E-15 à E2E-21, E2E-43, A11Y-07 à A11Y-11 MAN-03, AC-G-21
10 Footer « Passage & Clarté » AC-6.10-* E2E-24, E2E-31, visuel, A11Y-02 MAN-09
11 Système d'animation Motion AC-8-* E2E-28, E2E-37, E2E-41, E2E-42, check:bundle MAN-10
12 Pages légales et confidentialité AC-6.11-, AC-10- U-GRD-*, GT-01 à GT-03, E2E-25, E2E-44, A11Y-13, A11Y-14 contenu légal validé par Malka
13 SEO, accessibilité, performance AC-11-* E2E-27, E2E-34, E2E-35, E2E-36, E2E-43, I-HDR-, A11Y-, Lighthouse CI MAN-01 à MAN-14
14 Déploiement Docker AC-14-, AC-13- Tâche CI 3, GT-04, GT-05, U-ENV-* AC-G-17, AC-G-26

15.12.2 Critères d'acceptation globaux #

ID Critère Mesure / test
AC-G-01 Qualité statique pnpm lint (zéro avertissement), pnpm typecheck (mode strict), pnpm format:check passent
AC-G-02 Tolérance du typage Aucun any explicite ni @ts-ignore / @ts-expect-error sans description d'au moins 10 caractères (règles @typescript-eslint/no-explicit-any et ban-ts-comment)
AC-G-03 Build pnpm build réussit avec APP_ENV=staging ; avec APP_ENV=production il échoue tant que les éléments de §1.3 manquent (GT-02) et réussit une fois fournis (GT-03)
AC-G-04 Console Aucune erreur ni avertissement de console sur aucune route en Chromium, Firefox et WebKit (AC-15.6-05)
AC-G-05 Absence de traceurs Aucun cookie ; aucune écriture localStorage/sessionStorage par le site avec ANALYTICS_PROVIDER=none (E2E-14, E2E-33) ; avec umami, le stockage reste vide tant que le visiteur n'a pas cliqué sur le bouton d'opposition, seul umami.disabled pouvant alors être écrit (E2E-44)
AC-G-06 Aucune requête tierce à l'exécution E2E-33
AC-G-07 Pas de défilement horizontal de 320 à 1920 px E2E-29
AC-G-08 Pas de recouvrement du hero ni d'élément interactif masqué E2E-30, E2E-31
AC-G-09 Accessibilité automatique A11Y-01 à A11Y-17 : zéro violation grave ou critique ; Lighthouse Accessibilité = 100
AC-G-10 Performance de laboratoire Lighthouse mobile sur les builds de test : Performance ≥ 90, Bonnes pratiques ≥ 95, SEO = 100 (audit is-crawlable exclu, §15.8.1) ; l'indexabilité de la production est vérifiée au jalon M8 par le runbook de §14.11
AC-G-11 Signaux web LCP ≤ 2,5 s ; CLS ≤ 0,05 (E2E-36 + Lighthouse) ; TBT ≤ 200 ms ; latence d'interaction ≤ 200 ms (E2E-37)
AC-G-12 Budget JavaScript Premier chargement de / ≤ 170 Ko gzip (check:bundle)
AC-G-13 Mouvement réduit E2E-28a à E2E-28g
AC-G-14 Clavier et tactile E2E-20, E2E-38, E2E-39 ; MAN-08
AC-G-15 Aucun texte provisoire Aucune page ne contient de marqueur de texte provisoire : texte latin de remplissage, mots-clés de dette technique (tels que listés dans tests/e2e/helpers/forbidden-tokens.ts), [FIXTURE], dev-fallback, undefined, null, [object Object]. Ce fichier assemble les motifs à partir de fragments pour que l'analyse du dépôt ne le signale pas lui-même
AC-G-16 Aucun secret côté client GT-04 ; aucune variable NEXT_PUBLIC_* (U-ENV-13)
AC-G-17 Conteneur Image runner en utilisateur non-root nextjs (uid 1001) ; système de fichiers racine en lecture seule avec tmpfs pour /tmp et le cache ; docker compose ps indique healthy en 60 s ; le conteneur redémarre après arrêt forcé (restart: unless-stopped)
AC-G-18 Journaux sans données personnelles I-LOG-01 à I-LOG-05
AC-G-19 Invariants de contenu Quatre offres avec les prix 300, 150, 1 000 et « Sur devis » ; chiffres 300+ et 50+ ; coordonnées 06 67 29 51 79, astheoris.pro@gmail.com, « France — accompagnement à distance » (U-CNT-02, -03, -11, -12)
AC-G-20 Témoignages Section et lien de navigation absents tant qu'aucun témoignage n'est publiable ; aucun témoignage publiable sans consentement granted daté et citation non vide (U-TST-*, E2E-22, E2E-23)
AC-G-21 Envoi réel de bout en bout Au jalon M8, une soumission réelle depuis le site de production (EMAIL_DRY_RUN=false) : le message arrive dans la boîte astheoris.pro@gmail.com (boîte de réception, pas le dossier des courriers indésirables), Reply-To pointe vers l'adresse du visiteur de test, l'accusé de réception arrive à l'adresse de test ; résultat consigné avec capture dans docs/go-live-report.md
AC-G-22 Panne du fournisseur d'e-mail Quand Brevo est indisponible (simulé : clé volontairement invalide en préproduction), le visiteur voit le message d'échec avec les liens de repli WhatsApp et e-mail (E2E-17)
AC-G-23 Documentation README.md contient les 16 rubriques de §18.9.1, dans l'ordre ; DECISIONS.md existe
AC-G-24 Traçabilité des écarts Chaque écart à la spécification (dépendance ajoutée, seuil modifié, règle désactivée, test sauté) a une entrée DECISIONS.md
AC-G-25 SEO technique E2E-27 ; Lighthouse SEO = 100 ; lang="fr" partout
AC-G-26 Retour arrière Le retour à l'image précédente (make rollback TAG=<étiquette précédente>) a été exécuté une fois en préproduction ou en production et l'état de santé est redevenu healthy ; consigné dans docs/go-live-report.md
AC-G-27 Pages légales complètes en production Aucun champ légal vide ; aucune mention « à fournir » ; contenu relu et validé par Malka (voir §15.13)
AC-G-28 Validation visuelle Captures du hero et du pied de page aux largeurs 390, 834 et 1440, réalisées avec les vrais actifs, validées par Malka ; références visuelles régénérées (AC-15.9-03)
AC-G-29 Conformité éditoriale Aucune promesse de résultat, aucune allégation de santé, aucune statistique autre que 300+ et 50+, aucun témoignage ni nom de client inventé ; avertissement de l'orientation présent (C-ORI-06, C-ORI-13)
AC-G-30 Frontières du produit Aucun paiement en ligne, aucun calendrier en ligne, aucune base de données, aucun back-office, aucune version multilingue au lancement (revue de code : absence de dépendance ou de route correspondante)

15.13 Définition de « terminé » (Definition of Done) #

15.13.1 Pour une tâche ou une demande de fusion #

  1. Le code respecte pnpm lint, pnpm format:check et pnpm typecheck sans avertissement.
  2. Les tests de la tâche (unitaires, composants, intégration, E2E selon le cas) sont écrits avant ou avec le code et passent.
  3. Les tâches CI 1 à 5 et 7 à 8 de §15.11 sont vertes.
  4. Les critères d'acceptation de la sous-section concernée sont cochés dans la description de la demande de fusion, avec renvoi vers l'ID du test qui les prouve.
  5. Aucun texte visible n'est écrit en dur dans un composant : tout vient de src/content/fr/ (voir §9.3, §7).
  6. Aucune dépendance n'a été ajoutée sans l'entrée DECISIONS.md exigée par §18.6.
  7. Les états de chargement, vide, erreur, succès, désactivé, mouvement réduit, mobile, clavier et tactile du composant sont implémentés et testés.
  8. Si l'interface change : captures avant / après à 390 et 1440 jointes.

15.13.2 Pour un jalon (M0 à M8) #

Tous les points de 15.13.1 pour chaque tâche du jalon, plus les critères de sortie du jalon de §16, plus la mise à jour de docs/progress.md (état d'avancement, décisions, éléments en attente de Malka).

15.13.3 Pour le projet (mise en production) #

Le projet est « terminé » lorsque toutes les conditions suivantes sont réunies :

  1. AC-G-01 à AC-G-30 sont satisfaits, preuves archivées dans docs/go-live-report.md.
  2. Tous les jalons M0 à M8 sont clos (§16).
  3. Les éléments fournis par Malka (§1.3) sont intégrés : portraits, paysages, textes validés, témoignages (ou section masquée selon la règle), données légales complètes, domaine, compte et domaine d'envoi du fournisseur d'e-mail, clé d'API.
  4. La checklist manuelle MAN-01 à MAN-14 est consignée sans anomalie bloquante.
  5. L'envoi réel (AC-G-21) et le retour arrière (AC-G-26) sont validés en production ou en préproduction identique.
  6. Malka a validé par écrit (message conservé dans docs/go-live-report.md) : les textes marqués « proposition à valider par Malka », les prix, le contenu légal, les témoignages publiés, et le rendu visuel aux trois largeurs.
  7. Le dépôt est remis avec README.md complet, DECISIONS.md, .env.example complet (aucune valeur secrète) et la checklist de remise de §18.9 cochée.

16. Plan de développement par étapes #

Le développement est découpé en neuf jalons (M0 à M8), livrables et vérifiables isolément. Chaque jalon définit son objectif, ses tâches, ses livrables, ses dépendances (y compris les éléments que Malka doit fournir, listés en §1.3), ses critères de sortie sous forme de commandes ou de contrôles exécutables, et une taille relative. Les ressources de test et les critères d'acceptation cités sont ceux de §15.

16.1 Vue d'ensemble #

Jalon Titre Taille Dépend de Attente de Malka bloquante ?
M0 Initialisation, outillage et squelette Docker M — Non
M1 Design system, jetons, polices et contenu typé M M0 Non
M2 Structure de page, navigation et hero statique L M1 Non (actifs de repli)
M3 Système de mouvement et chorégraphie du hero L M2 Non
M4 Sections C à H (philosophie → témoignages) L M1, M3 Non (repli) ; textes validés en fin de jalon
M5 API de contact, e-mail et formulaire M M0 (API), M1 (interface) ; M4 pour les parcours E2E-12 à E2E-14 Compte Brevo pour les essais réels (facultatif avant M8)
M6 Pied de page, pages légales, SEO et en-têtes de sécurité M M2 Non (bandeau de dev)
M7 Durcissement : accessibilité, performance, tests complets, actifs réels L M3, M4, M5, M6 Actifs réels, témoignages, données légales (intégration)
M8 Déploiement en production et mise en ligne S–M M7 Domaine, VPS, compte et domaine d'envoi, clé d'API, données légales

Échelle de taille (effort relatif d'un exécutant autonome, revue comprise) : S ≈ 1 unité, M ≈ 2 à 3 unités, L ≈ 4 à 6 unités. Le total attendu est d'environ 25 à 39 unités (somme des fourchettes : quatre jalons M = 8 à 12, quatre jalons L = 16 à 24, M8 = 1 à 3). Une unité = une demi-journée d'un exécutant humain ; pour un agent, l'unité sert uniquement à ordonner les jalons, pas à planifier du temps.

Chemin critique :   M0 ─► M1 ─► M2 ─► M3 ─► M4 ─► M7 ─► M8
Branche parallèle:         M1 ─► M5 ──────────────┘
                           M2 ─► M6 ──────────────┘
Pistes externes :   [Malka : actifs, textes, légal, témoignages] ─────► M7/M8
                    [Opérations : VPS, DNS, domaine d'envoi Brevo] ───► M8

16.2 Jalons #

16.2.1 M0 — Initialisation, outillage et squelette Docker (taille M) #

Objectif. Un dépôt reproductible qui construit, teste et démarre dans Docker avec une route de santé fonctionnelle, avant toute fonctionnalité.

Tâches.

  1. Initialiser le dépôt Git, .gitignore, .dockerignore, .editorconfig, .nvmrc, README.md minimal (les 16 rubriques de §18.9.1 y figurent en titres) et DECISIONS.md (en-tête de §18.7).
  2. Activer pnpm via Corepack ; créer le projet Next.js (App Router, TypeScript strict, output: 'standalone') en installant les lignes de version de §9.1 (version stable courante à l'instant du build) ; produire le fichier de verrouillage.
  3. Installer et configurer ESLint (flat config unique de §12.8.1, avec les règles d'import des fixtures, de encodeURI et du composant motion complet), Prettier, Vitest (un fichier, trois projets, alias server-only, seuils par glob, §15.2.4), Playwright, @axe-core/playwright, Lighthouse CI ; créer les scripts de §12.9 (qui reprennent exactement ceux de §15.2.2). Le script build vaut next build jusqu'à M1, où pnpm check:assets lui est préfixé (le contrôle charge le contenu typé, qui n'existe pas encore).
  4. Créer l'arborescence de §12 avec des modules vides typés.
  5. Implémenter src/lib/env.ts (analyse Zod, valeurs par défaut, échec clair), src/lib/log/logger.ts (JSON lignes, aucune donnée personnelle, ipHash), src/instrumentation.ts, GET /api/health.
  6. Écrire Dockerfile multi-étapes (deps, builder, test, runner), compose.yaml (service web, healthcheck, journaux, système de fichiers en lecture seule), compose.dev.yaml, Makefile (cibles de §14), .env.example (voir §13).
  7. Créer le .env local de vérification (non versionné, jamais déployé), copié de .env.example puis ajusté : APP_ENV=staging, SITE_URL=http://localhost:3000, EMAIL_DRY_RUN=true, BREVO_API_KEY=dev-placeholder-0123456789, LOG_SALT d'au moins 32 caractères (valeur de test collée telle quelle), EMAIL_FROM_ADDRESS=dev@example.test. Sans lui, les arguments de construction de Compose valent APP_ENV=production avec un SITE_URL vide et la construction échoue volontairement (§14.2) ; tous les critères de sortie de M0 à M7 qui lancent Docker ou pnpm build utilisent ce fichier.
  8. Mettre en place la CI (jobs quality, unit-integration et docker-build de §15.11, §14.15.1) et les premiers tests unitaires : U-ENV-01 à U-ENV-14, I-HLT-01 à I-HLT-08.

Livrables. Dépôt initialisé ; image qui démarre ; route /api/health ; CI verte ; DECISIONS.md ; .env local de vérification.

Dépendances. Aucune. Aucun élément de Malka.

Critères de sortie.

test -f .env                                                    # .env local de vérification (tâche 7)
pnpm install --frozen-lockfile
pnpm lint && pnpm format:check && pnpm typecheck
pnpm test:unit && pnpm test:integration
pnpm build                                                      # `next build` à ce stade
docker compose build
docker compose up -d && sleep 25 && docker compose ps          # état « healthy »
curl -fsS http://localhost:3000/api/health | jq -e '.ok == true and .data.status == "ok"'
docker compose exec web id -u                                   # affiche 1001
docker compose down

16.2.2 M1 — Design system, jetons, polices et contenu typé (taille M) #

Objectif. Fondations visuelles et de contenu : tout composant ultérieur s'appuie sur des jetons et des données typées.

Tâches.

  1. Déclarer les jetons de couleur, rayons, espacements et typographie (§5) dans src/styles/globals.css (@theme) ; charger Fraunces (romain) et Manrope via next/font/google (auto-hébergées au build) avec les variables --font-display et --font-sans, puis une seconde instance de Fraunces en italique (preload: false, variable --font-display-italic) avec font-synthesis: none (§5.5).
  2. Primitives : Container, Section, Heading, Button (primaire, secondaire, tertiaire), VisuallyHidden, Icon (imports nominatifs lucide-react), SkipLink, styles d'anneau de focus (§5).
  3. Contenu : écrire src/lib/content/schemas.ts (§9.3.3, propriétaire des clés), les 13 fichiers de src/content/fr/ avec les valeurs livrées (offres, prix, chiffres, coordonnées, textes de §7), clé pour clé conformes à ces schémas, et le chargeur getContent('fr').
  4. Utilitaires : src/lib/cn.ts, src/lib/design/tokens.ts, formateur de prix fr-FR, constructeur de lien WhatsApp et getBookingAction (serveur uniquement), helper de typographie française src/lib/typography/fr.ts, track() (no-op), gardes de production (légal, actifs, marqueurs, registre de validation content-validation.json).
  5. Actifs de repli : public/images/dev-fallback/{hero-far,hero-mid,hero-near,hero-mobile,hero-portrait,about-portrait}.svg (paysages SVG abstraits, formes sans visage) ; registre docs/assets-licences.json (structure vide valide) et scripts/check-assets.ts (dimensions minimales via sharp, canal alpha des calques, entrée de licence, absence de repli en production) ; le script build devient pnpm check:assets && next build.
  6. Tests : U-CNT-* (dont U-CNT-17 : chaque fichier de contenu livré satisfait son schéma), U-TST-, U-FMT-, U-WA-, U-TYP-, U-AN-, U-DS-, U-GRD-*.
  7. Harnais E2E (il doit exister avant tout critère de sortie qui lance make test-e2e) : cible Dockerfile runner-fixtures et services Compose web-e2e, web-e2e-fixtures, e2e (§15.6.2, §14.4), playwright.config.ts sans webServer, tests/fixtures/ (contenu, légal, amorçages Vitest), tests/e2e/helpers/ (géométrie, contact, enveloppe axe), lighthouserc.json, scripts/check-no-fixtures.ts ; un test de fumée vérifie que /api/health répond 200 sur les deux services.

Livrables. Système de jetons, primitives, contenu typé validé, outils de formatage, actifs de repli.

Dépendances. M0. Éléments de Malka : aucun obligatoire ; les textes de §7 sont utilisés tels quels (propositions à valider).

Critères de sortie.

pnpm lint && pnpm typecheck
pnpm test:unit            # U-CNT, U-TST, U-FMT, U-WA, U-TYP, U-AN, U-DS, U-GRD tous verts
pnpm build                # le contenu invalide ferait échouer le build : vérifier en cassant un prix puis en annulant
APP_ENV=production pnpm build ; test $? -ne 0     # build négatif attendu (GT-02) : légal vide et actifs de repli
make test-e2e -- --grep "E2E-00"                   # test de fumée du harnais : /api/health sur web-e2e et web-e2e-fixtures

16.2.3 M2 — Structure de page, navigation et hero statique (taille L) #

Objectif. La page / existe avec sa coquille, une navigation complète et un hero fidèle à la composition « Hero 02 — Clarté », sans mouvement.

Tâches.

  1. app/layout.tsx (<html lang="fr">, polices, MotionConfig prévu à M3), lien d'évitement, <main>, repères ARIA, identifiants d'ancres et scroll-margin-top (§4.2).
  2. Navigation (§6.1) : en-tête collant toujours visible (états top, scrolled, solid), logo, liens en ligne à partir de 1280 px, bouton de menu en dessous (CTA « Faire le point » visible dès 640 px), état actif par IntersectionObserver, menu mobile plein écran accessible (role="dialog", piège de focus, verrouillage du défilement par html[data-scroll-locked]).
  3. Hero statique (§6.2) : paysage multicouche (actifs de repli), portrait détouré, mot « CLARTÉ » semi-transparent, rail de méthode (capsules dans la gouttière à partir de 1280 px, rangée horizontale en dessous), deux CTA, chiffres de confiance, transition douce vers la section suivante ; sous 1024 px, recomposition en bande encadrée (§4.4.2) ; toutes les images via next/image avec dimensions explicites, priority réservé à hero-far.
  4. Squelettes des sections C à J (titres et ancres) pour valider la navigation et l'état actif.
  5. Hooks de test de §15.2.5 pour la navigation et le hero.
  6. Tests : C-NAV-*, E2E-01 à E2E-07, E2E-29, E2E-30, E2E-31, E2E-36 (hero), A11Y-01, A11Y-04, A11Y-17.

Livrables. Page / navigable ; hero conforme aux wireframes de §4 à toutes les largeurs.

Dépendances. M1. Élément de Malka : le portrait réel est souhaitable ; l'absence est gérée par le repli (aucun visage généré).

Critères de sortie.

pnpm lint && pnpm typecheck && pnpm test:components
make test-e2e -- --grep "E2E-0[1-7]|E2E-29|E2E-30|E2E-31"     # voir §15.6
pnpm test:a11y -- --grep "A11Y-01|A11Y-04|A11Y-17"

Contrôle visuel : captures à 390, 834 et 1440 jointes à la demande de fusion ; aucun recouvrement (E2E-30 vert aux neuf largeurs).

16.2.4 M3 — Système de mouvement et chorégraphie du hero (taille L) #

Objectif. Le système de mouvement de §8 est opérationnel, le hero s'anime sans compromettre le LCP ni l'accessibilité.

Tâches.

  1. MotionConfig reducedMotion="user" et LazyMotion (domAnimation) dans layout/providers.tsx ; jetons de durée, de courbe et de cascade et variantes partagées dans src/lib/motion/ ; composants Reveal, RevealLines, StaggerGroup de src/components/motion/.
  2. Hooks de src/hooks/ (use-motion-profile, use-in-view-once, use-reveal-phase, use-ambient-state) et composants ParallaxLayer et AmbientDrift (pause hors écran et onglet masqué). Les composants rendent toujours des éléments m.* et alimentent des valeurs de mouvement qui restent à 0 quand les effets sont désactivés : un changement de profil ne remonte jamais les arbres.
  3. Chorégraphie du hero (séquence ≤ 2,2 s), apparition contrôlée du mot « CLARTÉ », parallaxe multicouche uniquement à partir de 1024 px avec pointeur fin et sans mouvement réduit (hero-far ≤ 120 px, mot 48 px, hero-mid 64 px, portrait 24 px, hero-near 0, texte jamais déplacé ni estompé ; aucune parallaxe au pointeur). Animations d'ambiance (halos, brume, reflets) : un seul cycle de 5 s au plus par entrée dans la fenêtre, état de repos symétrique, en pause hors écran, absentes sur mobile et en mouvement réduit.
  4. Version mobile simplifiée (sous 1024 px : paysage aplati hero-mobile dans la bande encadrée, pas de flou, pas de parallaxe).
  5. Règles ESLint de §12.8.1, script de budget scripts/check-bundle-size.ts (pnpm check:bundle) ; premier rapport de performance dans docs/perf-report.md.
  6. Tests : E2E-28, E2E-32, E2E-36, E2E-37, E2E-41, E2E-42 ; contrôles de §15.8.3.

Livrables. Système de mouvement documenté par ses usages ; hero animé ; mode réduit et mobile validés.

Dépendances. M2.

Critères de sortie.

pnpm check:bundle                                             # ≤ 174 080 octets gzip
make test-e2e -- --grep "E2E-28|E2E-32|E2E-36|E2E-37|E2E-41|E2E-42"
pnpm lighthouse                                               # seuils de §15.8.1 sur /

16.2.5 M4 — Sections C à H (taille L) #

Objectif. Les sections éditoriales et interactives sont complètes, pilotées par le contenu typé.

Tâches.

  1. Philosophie (§6.3) : typographie éditoriale, révélation ligne à ligne.
  2. Boussole de clarté (§6.4) : quatre phases en onglets à activation automatique (compas à gauche, panneau à droite à partir de 1024 px), clic / tactile / clavier, ancres profondes #methode-* (éléments vides id="methode-<id>"), événement method_step_open.
  3. Accompagnements (§6.5) : une offre mise en avant en panneau sombre puis des lignes éditoriales (OfferItem, variantes panel et row, sans grille de cartes), libellé « Tarif », prix formatés, détails en <details> natif, mention TVA depuis legal.ts, CTA « Écrire sur WhatsApp » (lien reçu en propriété depuis getBookingAction, appelé côté serveur) et lien « Écrire via le formulaire » (href="/?offre=<id>#contact", requestContactPrefill({ offerId })).
  4. Boussole d'orientation (§6.6) : groupe de radios natifs rendus côté serveur (liste statique sans JavaScript), résultat, avertissement, deux CTA (« Écrire sur WhatsApp », « Écrire via le formulaire » avec requestContactPrefill({ offerId, situationId })).
  5. À propos (§6.7) : structure et portrait (repli) ; texte conforme aux propositions de §7 marquées « à valider par Malka ».
  6. Témoignages (§6.8) : carrousel (au moins 2 témoignages publiables ; avec un seul, citation statique) sans lecture automatique ; rendu conditionnel de la section et du lien de navigation.
  7. Tests : C-CMP-, C-ORI-, C-TST-, C-OFR-, E2E-08 à E2E-11, E2E-22, E2E-23, A11Y-05, A11Y-06, A11Y-12. Les parcours qui traversent le formulaire (E2E-12, E2E-13, E2E-14) sont livrés et exécutés au jalon M5, où le formulaire existe.

Livrables. Page complète jusqu'au contact (sans le formulaire fonctionnel).

Dépendances. M1, M3. Éléments de Malka : texte de présentation et photographie de la section « À propos » ; consentements et textes des témoignages. Sans eux : repli, section Témoignages masquée par la règle.

Critères de sortie.

pnpm test:components
make test-e2e -- --grep "E2E-(0[89]|1[01]|22|23)"
pnpm test:a11y -- --grep "A11Y-0[56]|A11Y-12"
pnpm lighthouse

16.2.6 M5 — API de contact, e-mail et formulaire (taille M) #

Objectif. La demande de contact est reçue, validée, protégée contre les abus, transmise par e-mail, avec une interface complète dans tous ses états.

Tâches.

  1. src/lib/contact/schema.ts (schéma Zod partagé) ; POST /api/contact selon §9.4 : ordre normatif des contrôles (méthode, type de contenu, origine et Sec-Fetch-Site, taille, JSON, limitation de débit, spam, validation, notification, réponse 200, puis accusé de réception par after()), enveloppe d'erreur, requestId, méthodes non permises.
  2. EmailProvider et adaptateur Brevo (fetch natif, délai de 8 s, une reprise après 500 ms), gabarits échappés (§9.5), mode à blanc (EMAIL_DRY_RUN=true par défaut en développement, test et staging ; interdit en production).
  3. Limiteur de débit en mémoire derrière l'interface RateLimiter (dont les plafonds d'accusé : 2 par adresse et par 24 h, 5 par IP et par 24 h, 30 par heure au total), heuristiques anti-spam, gestion de TRUST_PROXY (IP cliente = dernière entrée de X-Forwarded-For, §9.6).
  4. Journalisation sans donnée personnelle (§9.7).
  5. Formulaire (§6.9) : états de chargement (aria-busy, champs en lecture seule, bouton aria-disabled), succès, erreurs de champ avec résumé focalisé, erreur serveur avec repli WhatsApp / e-mail, délai d'expiration client de 30 s, sélecteur visible « Votre situation (facultatif) », préremplissage par ?offre= (lu une fois) et par l'événement contact:prefill (le champ message n'est jamais prérempli), honeypot, startedAt avec calibrage d'horloge.
  6. Tests : U-CTC-, U-EML-, U-SP-, U-RL-, I-* (matrice complète), C-FRM-*, E2E-12 à E2E-21, A11Y-07 à A11Y-11.

Livrables. Contact fonctionnel en mode à blanc et, si les identifiants sont disponibles, en envoi réel de préproduction.

Dépendances. M0 (API), M1 (interface) ; l'intégration du formulaire dans la page (fin de M4) conditionne E2E-12 à E2E-14. Éléments de Malka (pour l'essai réel, non bloquants avant M8) : compte Brevo, domaine d'envoi authentifié, clé d'API, adresse d'expédition.

Critères de sortie.

pnpm test:unit && pnpm test:components && pnpm test:integration     # matrice I-* complète
make test-e2e -- --grep "E2E-1[2-9]|E2E-2[01]"
pnpm test:a11y -- --grep "A11Y-0[7-9]|A11Y-1[01]"
docker compose up -d && curl -fsS -X POST http://localhost:3000/api/contact -H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' -d '{}' | jq -e '.error.code == "VALIDATION_ERROR"'

(La dernière commande suppose SITE_URL=http://localhost:3000, valeur du .env local de vérification de M0 : l'en-tête Origin doit être égal à l'origine de SITE_URL, voir §9.4.)

16.2.7 M6 — Pied de page, pages légales, SEO et en-têtes de sécurité (taille M) #

Objectif. Les éléments transverses de conformité et de référencement sont en place.

Tâches.

  1. Pied de page « Passage & Clarté » (§6.10), entièrement vectoriel (montagnes et fleuve en SVG, aucun fichier d'image) : mot CLARTÉ discret, navigation, coordonnées, carte CTA flottante FloatingCtaCard (position: relative, chevauchement de la jonction Contact → Footer de --footer-overlap, au moins 64 px libres sous #contact, aucun recouvrement de texte).
  2. Pages /mentions-legales et /politique-de-confidentialite alimentées par legal.ts (dont la durée de conservation privacy.retention fournie par Malka) ; bandeau de développement ; sections de confidentialité de §6.11 dont la section audience conditionnelle.
  3. Pages système : not-found, error, global-error (§6.11).
  4. SEO (§11) : métadonnées, Open Graph et Twitter, public/og/og-default.jpg, sitemap.ts (toujours les 3 URL), robots.ts (Disallow: / hors production), données structurées si prévues.
  5. En-têtes de sécurité et CSP (§10) construits par le module pur src/lib/security/headers.ts utilisé par next.config.ts (HSTS : includeSubDomains en production seulement, 300 s en staging ; X-Robots-Tag hors production) ; module d'analyse optionnel (Umami) désactivé par défaut.
  6. Tests : E2E-24 à E2E-27, E2E-33, E2E-34, I-HDR-01 à I-HDR-04, U-SEO-, U-AN-08 à U-AN-10, C-ERR-, A11Y-13 à A11Y-15.

Livrables. Site complet fonctionnellement en préproduction.

Dépendances. M2. Éléments de Malka : données légales (intégration en M7/M8), pour le rendu final.

Critères de sortie.

make test-e2e -- --grep "E2E-2[4-7]|E2E-3[34]"
pnpm test:a11y -- --grep "A11Y-1[345]"
curl -sI http://localhost:3000/ | grep -i -E "content-security-policy|x-content-type-options|referrer-policy"
curl -fsS http://localhost:3000/sitemap.xml | grep -c "<loc>"        # affiche 3 (le plan du site liste toujours les 3 URL ; la protection hors production est portée par robots.txt, noindex et X-Robots-Tag)

16.2.8 M7 — Durcissement, tests complets et actifs réels (taille L) #

Objectif. Le site est prêt à la recette : tous les tests passent, les actifs réels sont intégrés, les rapports sont produits.

Tâches.

  1. Intégrer les actifs fournis par Malka, un fichier maître par emplacement déposé dans public/images/<dossier>/ : hero/{hero-far,hero-mid,hero-near,hero-mobile,hero-portrait}.webp (calques en WebP sans perte avec transparence), about/about-portrait.webp, public/og/og-default.jpg (le pied de page est vectoriel : aucun fichier) ; une entrée par fichier dans le registre docs/assets-licences.json ; remplacer les références dev-fallback ; exécuter pnpm check:assets.
  2. Intégrer les textes validés par Malka (présentation, témoignages avec consentements et grantedOn, mentions légales) ; sinon conserver le repli et consigner l'écart.
  3. Compléter la matrice de tests de §15 (dont E2E-43 et E2E-44) : E2E sur les cinq projets, 17 analyses axe, régression visuelle (régénération des références avec les vrais actifs, §15.9), Lighthouse CI sur 3 URL, couverture de §15.1.3.
  4. Exécuter la checklist manuelle MAN-01 à MAN-14 ; corriger ; rédiger docs/a11y-report.md et docs/perf-report.md.
  5. Revue de sécurité : en-têtes, CSP, journaux sans donnée personnelle, absence de secret dans le bundle (GT-04), pnpm audit.
  6. README complet (les 16 rubriques de §18.9.1) ; .env.example conforme à §13.

Livrables. Version candidate ; rapports d'accessibilité et de performance ; README ; références visuelles à jour.

Dépendances. M3, M4, M5, M6 ; éléments de Malka listés en §1.3 (actifs, textes, consentements, données légales).

Critères de sortie.

pnpm install --frozen-lockfile && pnpm lint && pnpm format:check && pnpm typecheck
pnpm test:coverage
make test-e2e
pnpm test:a11y
pnpm lighthouse && pnpm check:bundle
pnpm test:visual
pnpm check:guards && pnpm check:no-fixtures
pnpm audit --prod --audit-level=high

Tous passent ; docs/a11y-report.md et docs/perf-report.md existent. Si les éléments de Malka sont intégrés, APP_ENV=production pnpm build réussit (GT-03) ; sinon il échoue avec la liste des éléments manquants (GT-02) et le jalon est clos « sous réserve », M8 restant bloqué.

16.2.9 M8 — Déploiement en production et mise en ligne (taille S–M) #

Objectif. Le site est en ligne en HTTPS, surveillable, réversible, avec un envoi d'e-mail réel vérifié.

Tâches.

  1. Préparer le VPS : Docker et Compose installés, utilisateur dédié, pare-feu (22/tcp pour l'administration, 80/tcp, 443/tcp et 443/udp ; tout le reste fermé), enregistrement DNS du domaine vers le serveur. Vérifier par écrit auprès de Malka la confirmation B2B / B2C (élément M-22 de §1.3) : si elle vend aussi à des consommateurs, ses conditions générales et le médiateur de la consommation deviennent requis (§17).
  2. Authentifier le domaine d'envoi chez Brevo (enregistrements SPF, DKIM, DMARC fournis par Brevo ; DMARC débutant en mode de surveillance) ; créer la clé d'API dédiée ; générer LOG_SALT avec openssl rand -hex 32 dans un terminal et coller le résultat dans .env (jamais de substitution $(…) dans .env).
  3. Créer .env de production (voir §13) avec EMAIL_DRY_RUN=false (le mode à blanc est interdit en production : le démarrage échoue) ; construire avec APP_ENV=production et les arguments de build (§14) ; APP_VERSION = empreinte courte du commit.
  4. Choisir le mode de TLS : profil proxy (Caddy) si le VPS n'a pas déjà de proxy ; sinon déployer comme ressource Compose dans Coolify sans le profil proxy, en activant l'option « Connect to Predefined Network » de la ressource (§14).
  5. Déployer (make deploy), vérifier la santé, rejouer les tests de fumée sur l'URL publique (E2E @smoke avec PLAYWRIGHT_BASE_URL pointant vers la production, hors envoi réel). Vérifier l'indexabilité de la production : robots.txt autorise l'exploration et référence le plan du site, aucun en-tête X-Robots-Tag ni noindex (§14.11).
  6. Envoi réel de bout en bout (AC-G-21) : une soumission depuis le site, réception en boîte de réception de astheoris.pro@gmail.com (hors courriers indésirables), accusé de réception reçu ; si le message est classé indésirable, appliquer la procédure de R-05 (§17.2) avant la mise en ligne.
  7. Exercer le retour arrière (make rollback TAG=<étiquette précédente>, puis redéploiement ; sous Coolify le retour arrière passe par l'interface de Coolify, §14.14) (AC-G-26).
  8. Mesurer Lighthouse sur l'URL publique (make lighthouse LHCI_BASE_URL=https://<domaine>) ; remplir docs/go-live-report.md ; remettre le dépôt à Malka avec la checklist de §18.9.4 ; obtenir sa validation écrite (§15.13.3).

Livrables. Site en production ; docs/go-live-report.md ; remise.

Dépendances. M7 ; domaine, accès VPS, compte Brevo et domaine d'envoi authentifié, clé d'API, données légales complètes (§1.3).

Critères de sortie.

curl -fsS https://<domaine>/api/health | jq -e '.ok == true'
curl -sI https://<domaine>/ | grep -i -E "^(HTTP|strict-transport-security|content-security-policy)"
curl -sI http://<domaine>/ | grep -i "^location: https://"          # redirection HTTP → HTTPS
curl -sI https://www.<domaine>/ | grep -i "^location: https://<domaine>"   # www → apex (profil proxy)
docker compose ps                                                    # healthy
curl -fsS https://<domaine>/robots.txt | grep -i "^sitemap:"          # le plan du site est référencé
curl -sI https://<domaine>/ | grep -ic "x-robots-tag"                # affiche 0 (production indexable)
make rollback TAG=<étiquette précédente> && curl -fsS https://<domaine>/api/health && make deploy

Contrôles manuels : AC-G-21 (envoi réel), AC-G-26, AC-G-27 et AC-G-28.

16.3 Chemin critique et parallélisme #

Chemin critique. M0 → M1 → M2 → M3 → M4 → M7 → M8. Un retard sur l'un de ces jalons retarde la mise en ligne.

Pistes parallélisables (après la fin de M1) :

Piste Jalons Conditions de parallélisme
A — Interface M2 → M3 → M4 Séquentielle : chaque jalon réutilise les composants du précédent
B — Contact M5 Indépendante des pistes A et C ; l'interface du formulaire n'utilise que les primitives de M1 ; s'insère dans la page lors de l'intégration finale de M4
C — Transverse M6 (pied de page, légal, SEO, sécurité) Démarre dès la fin de M2 (coquille de page et en-tête disponibles)
D — Opérations Préparation du VPS, DNS, domaine d'envoi Brevo (délai de propagation jusqu'à 48 h), accès Coolify le cas échéant À lancer dès le jalon M0 ; ne dépend d'aucun code
E — Contenu de Malka Actifs, textes, consentements, données légales À demander dès le jalon M0 (liste exhaustive en §1.3) ; sa date de livraison pilote M7

Règles de fusion : les pistes B et C ne touchent la composition de la page (src/app/page.tsx) qu'en une modification courte et isolée, pour limiter les conflits.

Éléments à longue latence à lancer immédiatement : propagation DNS et authentification du domaine d'envoi (R-05), délais de consentement des témoignages (R-03), collecte des données légales (R-04), livraison et détourage des portraits (R-01, R-02), confirmation du public visé B2B / B2C (M-22).

16.4 Évolutions après la version initiale #

Le périmètre initial n'inclut aucun des éléments ci-dessous. Chacun s'appuie sur un point d'extension déjà prévu, de sorte que son ajout ne demande pas de refonte.

# Évolution Déclencheur Point d'extension utilisé Travail prévu
E-1 Prise de rendez-vous Cal.com Malka souhaite un agenda en ligne src/lib/booking/ : ajouter la valeur 'calcom' au type BookingProvider et la branche correspondante de getBookingAction ; nouvelle variable CALCOM_LINK (à ajouter à §13) ; composant d'intégration dans la section contact Étendre la CSP (§10) ; nouveau parcours E2E ; aucun changement des composants CTA qui ne construisent jamais d'URL
E-2 Version anglaise Public non francophone src/content/en/ (même schéma que fr), getContent(locale) déjà paramétré, <html lang> dynamique Segment [locale], balises hreflang, entrées de plan du site par langue, traduction des gabarits d'e-mail ; décision explicite sur le préfixe d'URL
E-3 Activation d'Umami Malka veut mesurer visites et clics ANALYTICS_PROVIDER=umami, UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID (§9.8) Reconstruire l'image avec ces arguments ; vérifier que la section analytique de la politique de confidentialité s'affiche ; exécuter U-AN-*, E2E-33 (variante base) et E2E-44 avec le fournisseur actif (l'origine d'Umami devient la seule requête tierce autorisée)
E-4 Limiteur de débit partagé (Redis) Passage à plusieurs instances ou spam observé malgré le limiteur Interface RateLimiter (§9.6) Nouvelle implémentation et variable d'environnement dédiée ; Redis ajouté à compose.yaml ; tests U-RL-* rejoués contre les deux implémentations
E-5 Anti-robot Cloudflare Turnstile Du spam atteint la boîte malgré honeypot + délai Étape de vérification insérée dans POST /api/contact après l'analyse JSON et avant la limitation de débit (§9.4.3) ; champ de jeton ajouté à contactRequestSchema Variables de clés, extension de la CSP, composant de widget accessible, tests d'intégration du code de refus ; mise à jour de la politique de confidentialité
E-6 Blog Besoin de contenu éditorial récurrent Nouveau groupe de routes (blog) ; contenu en fichiers typés ou MDX sous src/content/ ; sitemap.ts étendu Aucun impact sur la page d'accueil ; ajout de modèles de page, flux RSS, pages dédiées
E-7 Adaptateur Resend Remplacement ou secours du fournisseur d'e-mail Interface EmailProvider (§9.5) Nouvelle classe + sélection par variable ; tests d'intégration rejoués avec la même matrice
E-8 Pages de détail par accompagnement Besoin de référencer chaque offre offers.ts (champ slug), generateStaticParams Nouvelle route statique, entrées de plan du site, liens depuis les éléments d'offre
E-9 Conditions générales de vente et médiateur de la consommation Malka confirme vendre à des consommateurs (élément M-22 de §1.3) legal.ts (champ mediator, vérifié par la garde légale G-01 étendue), nouvelle route /conditions-generales alimentée par un fichier de contenu typé Information précontractuelle et modalités de rétractation fournies par Malka ; mise à jour de la politique de confidentialité et du plan du site ; nouveau parcours E2E

Ces évolutions ne sont pas engagées ; elles ne doivent pas être anticipées dans le code livré au-delà des points d'extension déjà décrits.

17. Risques, hypothèses, dépendances et arbitrages #

17.1 Conventions #

Probabilité (P) et impact (I) : F faible, M moyen, É élevé. Responsable : Malka (propriétaire du site et du contenu), Exécutant (agent ou développeur qui réalise la spécification), Opérations (personne qui administre le VPS et le DNS ; Malka ou son prestataire).

Identifiants de cette section : R-NN risques, H-NN hypothèses, DEP-NN dépendances externes, ARB-NN arbitrages techniques. Ils sont distincts des identifiants D-NN (décisions par défaut de §1.2), DK-N (décisions de déploiement de §14), DEC-NNN (entrées de DECISIONS.md), T-NN (menaces de §10.2) et G-NN (gardes de §9.3.7).

17.2 Registre des risques #

ID Risque P I Mesure d'atténuation Responsable
R-01 Qualité et droits des actifs visuels : photographies de Malka de résolution insuffisante, paysage sans licence commerciale valable, image réutilisée sans droit M É Résolutions minimales exigées (§1.3, §5) ; le build de production échoue tant qu'un actif de repli est référencé ; la licence (ou le justificatif de création originale) est archivée dans le registre versionné docs/assets-licences.json avant M8 ; aucune image n'est copiée depuis un site tiers sans autorisation écrite Malka / Exécutant
R-02 Qualité du détourage du portrait : contours dentelés ou halo clair sur fond sombre, ruinant la composition É M Détourage fourni en haute résolution avec canal alpha (≥ 2 400 px de hauteur) ; contrôle visuel à 1440 et 1920 sur fond sombre ; atténuation par un léger voile de dégradé derrière le portrait ; repli : portrait rectangulaire avec masque en dégradé (décision à consigner) ; jamais de visage régénéré ou retouché par IA Malka / Exécutant
R-03 Délai de consentement des témoignages : Stéphanie ou Émilie ne répond pas, ou refuse É F La section et le lien de navigation sont masqués tant qu'aucun témoignage n'est publiable (règle de §9.3) ; la mise en ligne n'est jamais conditionnée aux témoignages ; l'ajout ultérieur ne demande que l'édition de testimonials.ts Malka
R-04 Retard des données légales (SIRET, statut, adresse, hébergeur, directeur de publication) É É Le build de production échoue tant que les champs obligatoires sont vides ; collecte lancée dès M0 ; page de collecte sous forme de liste dans §1.3 ; sans données, la mise en ligne publique est bloquée (M8) Malka
R-05 Délivrabilité Brevo vers Gmail : les notifications arrivent en courrier indésirable ou sont rejetées (la boîte destinataire est une adresse Gmail) M É Domaine d'envoi authentifié (SPF, DKIM) et politique DMARC publiée avant M8 ; expéditeur sur le domaine du site ; sujet et contenu sobres ; test réel obligatoire (AC-G-21) ; si classement indésirable : créer côté Gmail un filtre « ne jamais envoyer dans le spam » pour l'expéditeur, marquer « pas du spam », puis retester ; le bloc de repli WhatsApp / e-mail reste proposé en cas d'échec Opérations / Malka
R-06 Limiteur de débit mono-instance : compteurs remis à zéro à chaque redémarrage, limites multipliées en cas de plusieurs instances M F Le déploiement est mono-instance (documenté en §14) ; le coupe-circuit global de 200 par heure borne l'impact ; l'interface RateLimiter permet un stockage partagé (E-4) ; le honeypot et le délai minimal restent actifs Exécutant
R-07 CSP avec 'unsafe-inline' : l'absence de nonce, imposée par la génération statique, affaiblit la protection contre le XSS M M Aucun contenu HTML utilisateur n'est rendu (échappement systématique, aucune injection de HTML brut) ; object-src 'none', base-uri 'self', frame-ancestors 'none', form-action 'self' ; pas de script tiers ; réévaluation avec hachages ou nonce si une page dynamique est introduite (§17.5, ARB-06) Exécutant
R-08 Taille du bundle Motion : dépassement du budget de 170 Ko gzip M M LazyMotion avec domAnimation et composants m.* ; règle ESLint interdisant le composant motion complet ; pnpm check:bundle bloquant ; chargement différé des effets de bureau (parallaxe) Exécutant
R-09 Performance mobile avec images en couches : LCP et TBT dégradés par plusieurs grandes images É É Sous 1024 px, une seule image aplatie dans la bande encadrée ; images servies par next/image en AVIF/WebP responsives ; dimensions explicites ; seule la couche lointaine est préchargée ; budget d'images au-dessus de la ligne de flottaison ≤ 350 Ko ; test de la section 15.8.3 ; flou de filtre limité au bureau Exécutant
R-10 Compatibilité de la ligne majeure de TypeScript : la chaîne d'outils de Next.js ne prend pas encore en charge la ligne majeure visée M F Règle de repli de §9.1 : utiliser la ligne majeure la plus récente prise en charge à l'instant du build, consignée dans DECISIONS.md ; aucun usage de fonctionnalité propre à la nouvelle ligne Exécutant
R-11 Conflits de proxy avec Coolify : ports 80/443 déjà occupés, certificat en double, double redirection M M Le profil proxy (Caddy) n'est jamais activé sous Coolify ; documentation de deux modes de déploiement (§14) ; test de santé et vérification des redirections en M8 Opérations
R-12 Abus du préremplissage WhatsApp : détournement de la fonction pour faire envoyer un texte arbitraire ou lien piégé F M Les messages sont tous construits à partir de gabarits du contenu typé ; le paramètre d'URL ?offre n'accepte que des identifiants connus, n'est jamais reflété dans la page ni dans un lien et est retiré de l'URL après sa lecture unique (E2E-12) ; la situation de la boussole ne figure dans aucune URL (E2E-13) ; le texte d'un lien WhatsApp ne contient jamais de donnée issue de l'URL ni d'une saisie du visiteur Exécutant
R-13 Spam malgré honeypot et délai minimal : robots plus élaborés M F Limiteur de débit ; accusé de réception sans écho de contenu (pas d'amplification) ; journalisation des abandons ; évolution documentée vers Turnstile (E-5, ARB-11) Exécutant
R-14 Indisponibilité ou quota de Brevo F M Délai d'expiration et reprise ; message d'échec et repli WhatsApp / e-mail (AC-G-22) ; adaptateur Resend possible (E-7) Exécutant
R-15 Fuite ou compromission de la clé d'API F É Clé uniquement en variable d'environnement d'exécution ; jamais dans le dépôt ni dans le bundle (test GT-04) ; clé dédiée au site, droits d'envoi uniquement ; procédure de rotation documentée (rubrique « Rotation de la clé d'API » du README, §18.9.1) Opérations
R-16 Dérive éditoriale : promesse de résultat, allégation de santé, statistique inventée, formulation non validée M É Garde-fous éditoriaux de §2 et §7 ; liste noire testée (C-ORI-13) ; invariants de contenu (AC-G-19, AC-G-29) ; textes « proposition à valider » relus par Malka avant M8 Malka / Exécutant
R-17 Indisponibilité réseau lors du build des polices : next/font/google télécharge les polices au build F M Les polices sont récupérées au build (jamais à l'exécution) ; cache de dépendances en CI ; si le réseau est coupé, le build échoue de façon explicite ; repli documenté : fichiers de police locaux avec next/font/local, décision à consigner Exécutant
R-18 Limites de téléchargement d'images sur le registre public F F Images de base épinglées par ligne majeure et tirées via --pull ; miroir ou cache de registre possible ; docker compose build --pull rejoué à chaque mise à jour Opérations
R-19 Instabilité des tests E2E et visuels M F Services de test Compose isolés (web-e2e, web-e2e-fixtures), IP uniques, horloge de délai humain, tolérance visuelle, références générées sous Linux uniquement, une reprise en CI et suivi des tests instables (§15.11) Exécutant
R-20 Évolution de l'URL WhatsApp ou du comportement wa.me F M Le constructeur est l'unique point de génération (U-WA-08) ; test d'or ; changement localisé Exécutant
R-21 Vulnérabilité d'une dépendance M M pnpm audit bloquant à gravité haute (job security de §15.11) ; mises à jour ciblées ; Dockerfile reconstruit avec --pull Exécutant / Opérations
R-22 Texte superposé à une image illisible (contraste variable du paysage) M M Voile de dégradé sous les textes ; vérification manuelle MAN-09 aux largeurs 390 et 1440 ; contrastes de §5 testés (U-DS-01) Exécutant
R-23 Public visé mal qualifié (B2B / B2C) : les séances payantes conclues par WhatsApp ou formulaire sont des contrats à distance ; si Malka vend à des consommateurs, des obligations d'information précontractuelle, de rétractation et de médiation de la consommation s'appliquent même sans paiement en ligne M É Hypothèse H-17 à confirmer par écrit par Malka avant M8 (élément M-22 de §1.3) ; si le public inclut des consommateurs : conditions générales, médiateur de la consommation (champ legal.mediator rendu obligatoire par la garde légale), page /conditions-generales (évolution E-9) ; la spécification ne fournit pas d'avis juridique Malka
R-24 Réseau Coolify : le service web rattaché à un réseau Compose personnalisé n'est pas joignable par le proxy de Coolify (page 502 alors que le conteneur est sain) M M Sous Coolify le réseau personnalisé n'est pas imposé à web ; l'option « Connect to Predefined Network » de la ressource est activée (§14.14) ; contrôle de M8 : la page répond par le domaine Coolify après le premier déploiement Opérations
R-25 Saturation du coupe-circuit global ou abus de l'accusé de réception : un script à corps valides épuise les 200 soumissions par heure ou fait envoyer des accusés à des adresses tierces (réputation du domaine d'envoi) M M Plafonds d'accusé de 2 par adresse et par 24 h, 5 par IP et par 24 h et 30 par heure au total ; l'accusé part après la réponse (after()) et n'altère jamais celle-ci ; ligne de dépannage dédiée au coupe-circuit dans l'exploitation (§10, §14) ; le repli WhatsApp / e-mail reste affiché Exécutant
R-26 Mémoire de l'optimisation d'images à l'exécution : l'encodage AVIF de grandes images par next/image dépasse la mémoire du conteneur M M Limite mémoire du conteneur de 768 Mo ; tailles et qualités autorisées restreintes (deviceSizes, qualities [60, 75]) ; localPatterns limité à /images/** ; un seul fichier maître par emplacement Exécutant
R-27 Adresse IP cliente erronée derrière un proxy : un proxy qui ajoute (au lieu de réécrire) X-Forwarded-For permettrait de contourner la limitation par IP si la première valeur était retenue ; derrière Cloudflare, tous les visiteurs partagent l'adresse du relais F M L'adresse retenue est la dernière entrée de X-Forwarded-For (celle de l'unique proxy de confiance) ; TRUST_PROXY=false si le conteneur est exposé sans proxy ; le plafond global borne l'impact (§9.6.5, §14.14) Exécutant / Opérations

17.3 Hypothèses #

ID Hypothèse Si elle est fausse
H-01 La composition « Hero 02 — Clarté » (vert forêt à gauche, lumière champagne à droite, portrait à droite, rail de méthode) est la composition contraignante Les wireframes de §4 et §6.2 sont à revoir ; les tests de géométrie (§15.6.6) sont à adapter
H-02 Seules des photographies réelles de Malka sont utilisées ; aucun visage n'est généré Violation du périmètre ; refus
H-03 Le paysage est une image sous licence couvrant l'usage web commercial, ou une création originale Le site ne peut pas être publié (R-01)
H-04 Le numéro WhatsApp est le numéro de téléphone 06 67 29 51 79, écrit en format international 33667295179 dans les liens Modifier site.contact (un seul endroit)
H-05 Les notifications sont envoyées à astheoris.pro@gmail.com Modifier CONTACT_TO_EMAIL (variable d'exécution, sans reconstruction) ; si le changement est durable, aligner aussi site.contact.email et la valeur par défaut de .env.example (le test U-ENV-14 exige leur égalité)
H-06 Un seul fournisseur d'e-mail (Brevo) est utilisé ; Malka crée le compte et authentifie un domaine d'envoi Sans domaine vérifié, la délivrabilité n'est pas garantie (R-05) ; le mode à blanc reste disponible
H-07 Le site est déployé en une seule instance sur un VPS Linux avec Docker et Compose Le limiteur doit devenir partagé (E-4)
H-08 Aucun traceur ni cookie publicitaire n'est utilisé ; l'analyse est désactivée par défaut et, si activée, Umami fonctionne sans cookie et sans donnée personnelle, la seule écriture de stockage étant l'indicateur umami.disabled posé à la demande explicite du visiteur Une bannière de consentement et une mise à jour de §10 seraient requises
H-09 Les chiffres 300+ et 50+ sont exacts et fournis par Malka (valeurs reprises de sa demande ; à reconfirmer par écrit avant M8) ; aucune autre statistique n'est publiée Éditer site.ts ; ne rien inventer
H-10 Les prix (300 €, 150 €, 1 000 €, sur devis) sont définitifs et affichés sans autre mention que celle de la TVA fournie par Malka Éditer offers.ts
H-11 Le français est la seule langue au lancement Voir E-2
H-12 Malka modifie le contenu en éditant des fichiers typés, puis déclenche un redéploiement (aucun CMS) Un CMS serait un nouveau périmètre
H-13 Aucune donnée n'est conservée au-delà de l'e-mail envoyé (pas de base, pas de sauvegarde des messages) Une base impliquerait une révision de §10 et de la politique de confidentialité
H-14 Les navigateurs visés sont les deux dernières versions majeures de Chromium, Firefox et Safari Adapter la matrice de §15.6.1
H-15 Les valeurs de SITE_URL, APP_ENV et de l'analyse sont connues au moment du build (variables de build) Reconstruire l'image
H-16 Le délai de réponse du fournisseur d'e-mail reste inférieur à 8 s en usage normal Augmenter le délai (décision consignée)
H-17 Les prestations de Malka sont vendues à des professionnels (B2B) ; les conditions commerciales sont communiquées hors du site Conditions générales, rétractation et médiateur de la consommation deviennent requis (R-23, E-9)

17.4 Dépendances externes #

ID Dépendance Rôle Obligatoire ? Quand ? Mode de défaillance et repli
DEP-01 Brevo (API transactionnelle, domaine d'envoi authentifié) Envoi des notifications et accusés Oui pour l'envoi réel Exécution Échec → 502 et repli WhatsApp / e-mail ; mode à blanc pour le développement
DEP-02 WhatsApp (wa.me) Ouverture de la conversation préremplie Oui Clic du visiteur Le lien est un simple href ; le numéro et l'e-mail restent visibles
DEP-03 Google Fonts, uniquement au build via next/font/google Téléchargement de Fraunces (romain et italique) et Manrope pour l'auto-hébergement Oui, au build Build Aucune requête à l'exécution (E2E-33) ; repli : next/font/local (R-17)
DEP-04 Docker Hub (images de base Node.js Alpine et Caddy) Construction et exécution Oui Build / déploiement Cache de registre ; versions voir §9.1 et §14
DEP-05 Registre de paquets npm Installation des dépendances (une fois, fichier de verrouillage) Oui Build Cache local ou miroir
DEP-06 Fournisseur d'hébergement VPS et DNS Exécution et résolution du domaine Oui Exécution Hors périmètre du site
DEP-07 Let's Encrypt (via Caddy) ou proxy Coolify Certificats HTTPS Oui (HTTPS) Exécution Renouvellement automatique ; surveillance de l'expiration
DEP-08 Umami (auto-hébergé ou service) Mesure d'audience optionnelle Non Exécution, si activé Si le script est bloqué, aucune erreur (U-AN-02)
DEP-09 Cal.com Prise de rendez-vous en ligne Non (hors périmètre initial) Évolution E-1 —
DEP-10 Cloudflare Turnstile Anti-robot Non (évolution E-5) Évolution —
DEP-11 Fournisseur d'actifs visuels (licence du paysage) Paysage panoramique sous licence Oui, ou création originale Avant M7 Repli de développement uniquement ; jamais en production
DEP-12 Hébergeur déclaré dans les mentions légales Information légale Oui Avant M8 Bloque la production (R-04)

17.5 Arbitrages techniques #

ID Décision Alternatives étudiées Pourquoi ce choix À réexaminer quand
ARB-01 Brevo comme fournisseur d'e-mail Resend ; SMTP direct ; autre API Société française avec données hébergées dans l'Union européenne (cohérence RGPD) ; API REST simple appelable avec fetch natif, sans SDK Problèmes de délivrabilité persistants ou politique tarifaire défavorable → adaptateur Resend (E-7)
ARB-02 Aucune base de données SQLite ; PostgreSQL ; file de messages Le besoin est de transmettre une demande, pas de la conserver ; réduit le risque RGPD (aucune donnée personnelle stockée) et l'infrastructure Besoin d'historique des demandes, de relance ou de tableau de bord
ARB-03 Limitation de débit en mémoire Redis ; limitation au niveau du proxy ; service tiers Aucune dépendance ajoutée ; suffisant pour un site mono-instance à faible trafic ; interface prête pour un stockage partagé Passage à plusieurs instances, spam observé, ou redémarrages fréquents (E-4)
ARB-04 Génération statique (SSG) pour / et les pages légales Rendu dynamique à la demande ; rendu hybride Performance maximale, coût d'exploitation minimal, aucun état serveur ; le contenu est typé et connu au build Contenu modifiable sans reconstruction, ou personnalisation par visiteur
ARB-05 Variables au build vs à l'exécution : les variables qui influencent la sortie statique (SITE_URL, APP_ENV, analyse) sont des arguments de build ; secrets et réglages de l'e-mail sont à l'exécution Tout à l'exécution ; tout au build Une page statique ne peut pas lire l'environnement à l'exécution ; les secrets ne doivent jamais être intégrés à l'image Passage à un rendu dynamique ou besoin de changer SITE_URL sans reconstruire
ARB-06 CSP sans nonce (avec 'unsafe-inline' pour les scripts de Next.js) CSP à nonce (impose le rendu dynamique) ; CSP à hachages La génération statique est incompatible avec un nonce par requête ; les risques sont réduits par l'absence de HTML utilisateur et les autres directives restrictives Introduction de pages dynamiques ou évolution de Next.js facilitant les hachages
ARB-07 LazyMotion avec domAnimation Composant motion complet ; CSS seul ; domMax Charge minimale de JavaScript pour les animations demandées ; domMax est réservé aux animations de mise en page, inutiles ici Besoin d'animations de mise en page ou de glissement (drag) au-delà de l'usage actuel
ARB-08 Fraunces et Manrope Autres familles serif/sans ; polices système Serif éditorial variable à axe optique pour des titres monumentaux (romain, et italique chargé en seconde instance sans préchargement pour éviter l'italique synthétique) ; sans moderne lisible pour l'interface ; deux familles variables auto-hébergées pour limiter le poids et supprimer toute requête tierce Retours de lisibilité, changement d'identité
ARB-09 Pas de lecture automatique du carrousel Défilement automatique avec bouton de pause Accessibilité (2.2.2), calme visuel, contrôle du visiteur, et simplicité Jamais en l'absence de demande explicite et d'un bouton de pause accessible
ARB-10 Aucune bannière de consentement Bannière systématique ; analyse conditionnée Aucun traceur, aucun cookie ; l'analyse est désactivée par défaut ; Umami fonctionne sans cookie ni donnée personnelle, ce qui entre dans le cadre des mesures d'audience dispensées de consentement, avec un bouton d'opposition (seule écriture de stockage : umami.disabled, à la demande du visiteur) (raisonnement détaillé en §10) Activation d'un outil qui dépose des traceurs, ajout de contenu intégré tiers (vidéo, carte), Cal.com avec cookies
ARB-11 Honeypot + délai minimal, sans CAPTCHA reCAPTCHA ; hCaptcha ; Cloudflare Turnstile Pas de script tiers, pas de friction, pas de donnée transmise à un tiers ; suffisant au volume attendu Spam observé malgré le limiteur → Turnstile (E-5)
ARB-12 Contenu en fichiers TypeScript typés, validés par Zod au build CMS ; fichiers JSON ; Markdown Typage fort, erreurs au build, aucune infrastructure ; compatible avec le périmètre « sans back-office » Malka souhaite éditer sans dépôt ni redéploiement
ARB-13 Validation partagée client / serveur (un seul schéma Zod) Deux schémas séparés Évite les divergences ; une seule source pour les messages d'erreur et les bornes Besoin de règles propres au serveur uniquement
ARB-14 Sortie standalone dans une image non-root à système de fichiers en lecture seule Image complète ; processus root Surface d'attaque et taille réduites ; conformité aux bonnes pratiques Fonction nécessitant l'écriture disque
ARB-15 Régression visuelle limitée au hero et au pied de page, sous Linux seulement Visuel sur toutes les sections ; service tiers de comparaison Coût d'entretien limité, rendu reproductible, pas de service externe Bascule vers un outil dédié si le nombre de compositions fragiles augmente
ARB-16 Aucune dépendance de gestion de formulaire (champs contrôlés + schéma Zod) React Hook Form Un seul formulaire de six champs ; moins de JavaScript Plusieurs formulaires ou logique conditionnelle complexe
ARB-17 Un fournisseur de réservation unique : WhatsApp derrière BookingProvider Cal.com dès le lancement Cohérence avec le périmètre confirmé et réduction des risques RGPD ; extension prévue sans refonte Demande d'agenda en ligne (E-1)
ARB-18 Images servies par next/image (sharp à l'exécution), un fichier maître par emplacement Pipeline de pré-optimisation avec variantes et manifeste ; <img> brut Aucun outil de préparation à maintenir, formats AVIF et WebP négociés par le navigateur, remplacement d'une photo = remplacement d'un fichier ; coût : mémoire du conteneur (R-26) Trafic élevé ou mémoire insuffisante → pré-optimisation au build
ARB-19 Adresse IP cliente = dernière entrée de X-Forwarded-For Première entrée ; adresse de connexion Un proxy qui ajoute l'en-tête rend la première entrée falsifiable ; la dernière est écrite par l'unique proxy de confiance, avec un résultat identique quand l'en-tête n'a qu'une valeur Plusieurs proxys en chaîne → liste d'adresses de confiance
ARB-20 Plan du site toujours à 3 URL, protection hors production par robots.txt (Disallow: /), noindex et X-Robots-Tag Plan du site vide hors production Les tests fonctionnent sur des builds de test ; trois verrous suffisent à éviter l'indexation Besoin de masquer aussi les URL → authentification de l'environnement de test
ARB-21 E2E dans Docker Compose (services web-e2e, web-e2e-fixtures, lanceur e2e) Serveurs lancés par Playwright sur l'hôte Le build testé est l'image réelle ; une seule configuration pour l'hôte, la CI et Docker ; les fixtures n'existent que dans une cible d'image dédiée Changement d'hébergeur de CI incompatible avec Compose

18. Instructions pour l'agent exécutant #

Cette section s'adresse à l'agent ou au développeur qui réalise le site à partir de ce document seul. Elle fixe la méthode : ordre de lecture, hiérarchie d'autorité entre sections, boucle d'exécution par jalon, traitement des éléments manquants, interdits, traçabilité des décisions et remise à Malka.

18.1 Principes d'exécution #

  1. Ce document est le contrat. Aucun contexte extérieur n'est nécessaire. Quand une information manque, appliquer la règle de §18.5 ; ne jamais inventer.
  2. Faire correct avant de faire vite. Chaque jalon se clôt par ses commandes de vérification (§16) ; on n'ouvre pas le jalon suivant avec un test rouge.
  3. Le périmètre est fermé. Ne rien ajouter qui ne soit pas dans §3 ; ne rien retirer. Les évolutions de §16.4 ne sont pas à anticiper.
  4. Tout écart est tracé dans DECISIONS.md (§18.7).
  5. La sécurité et l'éthique priment sur le design : pas de suivi, pas de faux contenu, pas de promesse de résultat.

18.2 Ordre de lecture #

Lire dans cet ordre avant d'écrire la première ligne de code (cet ordre est celui de §1.1.2) :

Ordre Sections Pourquoi
1 §1 (valeurs par défaut et éléments fournis par Malka) puis §2 ; §3 (tableau de référence des exigences) se consulte tout au long du projet Périmètre, public, ce qui manque et comment le traiter
2 §18 (la présente section) Méthode, autorité entre sections, interdits : à lire avant d'écrire la première ligne de code
3 §9.1 (pile et versions) et §13 (variables d'environnement), puis §12 (arborescence et conventions), puis §9.2 à §9.8 Installer les bons outils, configurer l'environnement, structure du code, contenu typé, contrats d'API, e-mail, limitation, journaux, analyse
4 §5 (jetons) et §8 (mouvement), puis §4 (architecture UX, ancres) et §6.1 à §6.11, puis §7 (textes français, avec les passages « proposition à valider par Malka ») Vocabulaire visuel, animation, spécification de chaque section : au moment d'implémenter chaque jalon qui les concerne
5 §10 et §11, puis §14 Sécurité, RGPD, SEO, accessibilité, performance, Docker et déploiement : avant le jalon correspondant
6 §16 (plan) et §15 (tests et critères d'acceptation) Exécuter le plan dans l'ordre, jalon par jalon ; un jalon n'est terminé que lorsque ses critères de §15 et les critères AC-… de chaque section passent
7 §17 Risques, hypothèses, dépendances et arbitrages : à relire avant toute déviation et avant M7

18.3 Hiérarchie d'autorité #

Les numéros de section font foi. En cas d'écart apparent entre deux sections, la section propriétaire l'emporte :

Sujet Section propriétaire
Pile, dépendances et versions §9.1
Schémas de contenu et règles de validation §9.3
Contrat d'API et enveloppe d'erreur §9.4
E-mail §9.5
Limitation de débit et anti-spam §9.6
Journaux §9.7
Événements d'analyse §9.8
Variables d'environnement (liste complète) §13
Jetons de design §5
Jetons et variantes de mouvement §8
Ancres et identifiants §4.2
Composants et gabarits de page §6
Hiérarchie de sécurité HTTP §10.3
Textes français §7
Matrice de tests §15
Jalons §16
Déploiement §14
Arborescence, nommage et conventions de commit §12 (commits : §12.13.1)
Gardes de production G-00 à G-11 §9.3.7 (liste en §1.3.3) ; leurs tests portent les identifiants GT-01 à GT-05 de §15.10.4
Rubriques du README.md §18.9.1
Dépendances externes (DEP-xx) et arbitrages (ARB-xx) §17

Règles de résolution :

  1. Une valeur chiffrée n'est valable que dans la section propriétaire ; si elle est répétée ailleurs avec un écart, appliquer celle de la section propriétaire et consigner l'écart dans DECISIONS.md.
  2. Les exigences de sécurité, d'accessibilité et de non-collecte de données l'emportent sur les exigences esthétiques.
  3. Quand deux exigences de même niveau sont réellement incompatibles, choisir l'option la plus sûre et la plus accessible, et la consigner.

18.4 Boucle d'exécution par jalon #

Pour chaque jalon M0 à M8 de §16, dans l'ordre :

  1. Lire la description du jalon et les sections qu'elle cite.
  2. Créer une branche feat/mN-<sujet> à partir de la branche principale à jour.
  3. Écrire les tests d'abord ou avec le code pour chaque tâche (identifiants de §15).
  4. Implémenter en respectant §12 (arborescence, nommage, conventions) ; aucun texte visible en dur dans un composant.
  5. Vérifier localement avec les commandes ci-dessous.
  6. Valider les critères de sortie du jalon (bloc de commandes de §16) ; coller leur sortie dans docs/progress.md.
  7. Consigner les décisions dans DECISIONS.md et mettre à jour docs/progress.md.
  8. Fusionner (après CI verte) ; étiqueter le commit du jalon mN.
Moment Commandes de vérification
Avant chaque commit pnpm lint && pnpm typecheck && pnpm test:unit
Avant chaque fusion pnpm lint && pnpm format:check && pnpm typecheck && pnpm test:coverage && docker compose build
Fin de M0 à M2 Critères de sortie de §16 correspondants
Fin de M3 pnpm check:bundle et E2E de mouvement
Fin de M5 Matrice I-* complète et E2E de formulaire
Fin de M7 Toute la chaîne de §15.11 + checklist manuelle
Fin de M8 Critères de sortie de M8 et §15.13.3

Conventions de commit : celles de §12.13.1 (types, langue, longueur ; par exemple feat(contact): add in-memory rate limiter). Un commit ne mélange pas fonctionnalité et refactorisation. Aucun secret n'est jamais commité ; aucun contournement de contrôle (--no-verify) n'est utilisé.

Format du compte rendu d'avancement dans docs/progress.md à chaque jalon :

### M3 — Système de mouvement — terminé le AAAA-MM-JJ
- Tâches réalisées : …
- Preuves : sortie de `pnpm check:bundle` (taille), liste des tests verts
- Décisions : DEC-004, DEC-005
- Écarts / réserves : aucun
- Éléments en attente de Malka : portrait détouré (repli utilisé)

18.5 Éléments manquants fournis par Malka #

La liste exhaustive des éléments à fournir est en §1.3. Quand l'un manque, l'exécutant continue avec le repli de développement, ne l'invente jamais, et garde les garde-fous de production actifs :

Élément manquant Repli de développement Garde-fou de production Comment le détecter
Portrait du hero (détouré) Forme abstraite sans visage dans public/images/dev-fallback/ (hero-portrait.svg) Le build de production échoue si dev-fallback est référencé G-02
Portrait « À propos » Idem (about-portrait.svg) Idem G-02
Paysage du hero (calques et version mobile) Dégradés SVG abstraits dans dev-fallback (le pied de page est vectoriel : aucun fichier à fournir) Idem ; dimensions, ratio et canal alpha contrôlés ; entrée de licence exigée dans docs/assets-licences.json avant M8 G-02, G-07, G-08, AC-G-28
Textes de présentation de Malka Propositions de §7 marquées « à valider par Malka » Registre content-validation.json : un build de production échoue tant que Malka n'a pas validé chaque clé ; liste des textes à valider cochée dans la remise (§18.9.4) ; validation écrite (§15.13.3) G-05, revue finale
Témoignages (textes, consentements) published:false, consent.status:'pending', quote:'' ; section et lien de navigation masqués Règle de §9.3 (publication = consentement accordé + date + citation non vide) U-TST-*, E2E-22
Données légales (SIRET, statut, adresse, hébergeur, directeur de publication, TVA, durée de conservation des données) Bandeau « Informations légales à fournir par Malka avant mise en production » en environnement non productif Le build de production échoue si un champ obligatoire est vide G-01
Domaine / SITE_URL http://localhost:3000 en développement Validation d'SITE_URL en production (U-ENV-09) G-06, U-ENV-*
Compte Brevo, domaine d'envoi, clé d'API EMAIL_DRY_RUN=true (développement, test et staging uniquement) Le démarrage en production échoue sans clé (U-ENV-03) et avec EMAIL_DRY_RUN=true (U-ENV-02b) G-04, U-ENV-*, AC-G-21
Confirmation du public (professionnels ou consommateurs, M-22) Hypothèse B2B (H-17) consignée dans DECISIONS.md Aucun contrôle automatique : M8 reste bloqué jusqu'à la confirmation écrite de Malka (§1.4.1) ; si le public inclut des consommateurs, évolution E-9 Revue finale, R-23
Décision d'activer l'analyse ANALYTICS_PROVIDER=none Test E2E-33 (aucune requête tierce) E2E-33
Accès VPS / Coolify Déploiement local avec Compose Étapes de M8 non closes §16 M8

Si un élément n'est toujours pas fourni à M7 : clore M7 « sous réserve », documenter l'écart dans docs/progress.md, ne pas contourner un garde-fou, et ne pas passer à M8.

18.6 Actions interdites #

L'exécutant ne fait jamais :

  1. Rédiger, paraphraser ou imaginer un témoignage ; publier un témoignage sans consentement granted daté (§9.3) ; ajouter un nom de client autre que ceux prévus ; afficher une statistique autre que 300+ et 50+.
  2. Générer, retoucher par IA ou « reconstituer » un visage ou un portrait de Malka ou d'une autre personne ; utiliser une photographie de banque d'images pour représenter Malka ou un client.
  3. Inventer une donnée légale (SIRET, statut, adresse, hébergeur, directeur de publication, mention de TVA, médiateur) ; copier ces données depuis une source non fournie par Malka.
  4. Ajouter un traceur, un cookie non strictement nécessaire, un pixel publicitaire, un script ou une police tiers chargés à l'exécution, une carte ou une vidéo intégrées, ou activer l'analyse par défaut.
  5. Ajouter une variable NEXT_PUBLIC_*, ou lire un secret côté client.
  6. Ajouter une dépendance non listée en §9.1 sans d'abord consigner une entrée DECISIONS.md (motif, alternatives, poids, licence, alternative sans dépendance écartée).
  7. Désactiver, affaiblir ou contourner un garde-fou de production, un test, une règle de lint, un seuil de performance ou d'accessibilité ; utiliser test.skip sans entrée DECISIONS.md ; supprimer un test qui échoue au lieu de corriger le code.
  8. Modifier les valeurs de jetons (§5, §8), les ancres (§4.2), les codes et enveloppes d'API (§9.4) ou le périmètre fonctionnel (§3) sans l'accord du propriétaire du document ; le cas échéant, consigner la demande dans DECISIONS.md et conserver la valeur de la spécification.
  9. Écrire une promesse de résultat, une allégation de santé ou thérapeutique, un diagnostic, une garantie ; modifier les avertissements de §7.
  10. Stocker, journaliser ou transmettre à un tiers un contenu de message, un nom, un e-mail, un numéro de téléphone, une adresse IP complète ou une situation d'orientation.
  11. Ajouter une base de données, un CMS, un paiement en ligne, un calendrier, un blog, un espace client, une bannière de consentement non requise, une version multilingue au lancement.
  12. Animer des propriétés de mise en page (width, height, top, left, margin), ou ignorer prefers-reduced-motion.
  13. Commiter un secret, une clé, un fichier .env ; écrire un secret dans un journal, une capture ou un rapport.
  14. Publier ou déployer en production sans validation de la porte de §15.11.
  15. Utiliser du texte provisoire, des données de démonstration, des liens # morts ou des boutons sans action dans un état livrable.

18.7 Enregistrer les décisions : DECISIONS.md #

DECISIONS.md se trouve à la racine du dépôt. Une entrée est obligatoire pour : toute dépendance ajoutée hors §9.1, tout écart à une valeur de la spécification, toute désactivation de règle ou de test, toute exception de seuil, tout repli technique (par exemple la version de TypeScript, les polices locales), toute ambiguïté tranchée, toute demande de modification adressée au propriétaire du document, et l'inversion GT-02 → GT-03 à M8. Les entrées ne sont jamais supprimées ; une décision remplacée est marquée « remplacée par DEC-NNN ».

Gabarit :

# Journal des décisions

### DEC-001 — <titre court>
- **Date** : AAAA-MM-JJ
- **Jalon** : M<N>
- **Statut** : acceptée | remplacée par DEC-NNN | à réexaminer le AAAA-MM-JJ
- **Contexte** : <situation, section(s) de la spécification concernée(s), par exemple §9.1>
- **Décision** : <ce qui a été décidé, de façon vérifiable>
- **Alternatives étudiées** : <liste courte, avec raison du rejet>
- **Conséquences** : <effet sur le code, les tests, les performances, la sécurité>
- **Vérification** : <commande ou test qui prouve que la décision tient>

Exemple de forme (indicatif, à adapter) :

### DEC-002 — Ligne majeure de TypeScript
- **Date** : AAAA-MM-JJ
- **Jalon** : M0
- **Statut** : acceptée
- **Contexte** : §9.1 prévoit la ligne la plus récente prise en charge par la chaîne d'outils de Next.js.
- **Décision** : utiliser la ligne majeure N-1, car la chaîne d'outils ne prend pas en charge la ligne N au moment du build.
- **Alternatives étudiées** : forcer la ligne N (échec du build).
- **Conséquences** : aucun changement de code.
- **Vérification** : `pnpm typecheck` ; `pnpm build`.

18.8 Gestion des blocages et des conflits #

  1. Conflit entre deux sections : appliquer §18.3, consigner dans DECISIONS.md.
  2. Ambiguïté : choisir l'option la plus conservatrice (sécurité, accessibilité, moindre collecte de données, moindre périmètre), la consigner, continuer.
  3. Test rouge sans cause claire : isoler (relancer seul, relancer avec trace Playwright), corriger le code et non le test ; ne pas augmenter une tolérance sans entrée DECISIONS.md.
  4. Dépendance externe indisponible (registre, polices au build, Brevo) : ne pas contourner par une valeur inventée ; utiliser le repli prévu (§17.4), consigner, reprendre.
  5. Élément de Malka manquant : §18.5.
  6. Demande qui sort du périmètre : ne pas l'exécuter ; la consigner dans le backlog de docs/progress.md (rubrique « Évolutions », renvoi à §16.4 si elle correspond).

18.9 Définition de « terminé » du projet et remise à Malka #

La définition de « terminé » du projet est celle de §15.13.3 (AC-G-01 à AC-G-30 satisfaits et archivés dans docs/go-live-report.md, jalons M0 à M8 clos, validation écrite de Malka). L'exécutant ne déclare le projet terminé que lorsque chaque point est prouvé par une sortie de commande, un rapport ou une capture.

18.9.1 Rubriques obligatoires du README.md #

Cette liste de 16 rubriques est le contrat unique du README.md : les titres sont repris tels quels, dans cet ordre (§12.12 y renvoie).

# Rubrique Contenu minimal
1 Présentation Objet du site, périmètre, schéma d'architecture simplifié
2 Prérequis Docker et Compose, Make, Corepack/pnpm pour le développement local
3 Démarrage local make dev (qui ne lit pas .env), ports, mode à blanc pour l'e-mail ; cp .env.example .env n'est nécessaire que pour les essais proches de la production (docker compose up)
4 Variables d'environnement Tableau reprenant §13 (sans valeur secrète) : nom, build ou exécution, défaut, obligatoire
5 Modifier les contenus Tableau « Je veux changer… → je modifie… » (voir 18.9.2)
6 Remplacer les photos Procédure de remplacement d'une photo (voir 18.9.2) : formats, dimensions minimales, nom suffixé -v2, preuve de licence ; aucun script de conversion
7 Tests Commandes make test, make test-e2e, make lighthouse, mise à jour des références visuelles
8 Déploiement Premier déploiement sur le VPS (avec et sans Coolify), arguments de build, création du .env (génération de LOG_SALT par openssl rand -hex 32, résultat collé, jamais de $(…))
9 Mise à jour git pull → docker compose build --pull → docker compose up -d → vérification de santé ; liste de contrôle hebdomadaire et tableau « Journal de maintenance » (date, action, résultat)
10 Retour arrière make rollback TAG=<étiquette précédente> (étiquettes = empreintes de commit), images conservées (les trois dernières) ; sous Coolify, par l'interface de Coolify
11 Rotation de la clé d'API Procédure de 18.9.3
12 Journaux et supervision make logs, format JSON, événements importants, absence de données personnelles
13 Sécurité et vie privée Résumé des mesures, absence de cookies, quand une bannière devient nécessaire ; fiche du registre de validation content-validation.json (qui valide quoi, et quand)
14 Dépannage Messages d'erreur fréquents : build de production refusé (légal ou actifs), 429, 502, e-mail en spam
15 Évolutions Résumé de §16.4 avec les points d'extension
16 Décisions Renvoi vers DECISIONS.md (entrées DEC-NNN)

18.9.2 Guide d'édition du contenu pour Malka #

Je veux changer… Je modifie… Puis
Un prix, un titre ou une description d'accompagnement src/content/fr/offers.ts Redéployer ; les erreurs de saisie bloquent la construction avec un message explicite
Le message WhatsApp prérempli d'une offre whatsappMessage dans offers.ts idem
Les chiffres 300+ / 50+ src/content/fr/site.ts, trustFigures idem
Mon téléphone, mon e-mail src/content/fr/site.ts, contact (numéro d'affichage, format international, chiffres WhatsApp : les trois doivent rester cohérents) idem
Publier un témoignage src/content/fr/testimonials.ts : recopier la citation à l'identique, passer consent.status à 'granted', renseigner grantedOn (AAAA-MM-JJ) et channel, puis published: true idem ; la section et le lien de navigation apparaissent automatiquement
Retirer un témoignage Mettre published: false idem ; si plus aucun témoignage n'est publiable, la section disparaît
Les situations de la boussole d'orientation src/content/fr/orientation.ts idem
Les textes de la page Fichiers de src/content/fr/ correspondants idem
Les mentions légales src/content/fr/legal.ts idem ; la construction de production refuse les champs vides
Une photo (portrait du hero, calques du paysage, portrait « À propos ») Déposer le nouveau fichier maître, au format et aux dimensions minimales de l'emplacement, sous un nouveau nom suffixé -v2 (puis -v3…) dans le même dossier de public/images/ (les images sont mises en cache un an) ; mettre à jour la référence (src, width, height, alt) dans le fichier de contenu concerné et la ligne correspondante de docs/assets-licences.json ; lancer pnpm check:assets (dimensions, canal alpha des calques, licence) Redéployer ; si le hero ou le pied de page changent d'aspect, régénérer les références visuelles dans l'image Playwright (pnpm test:visual:update, §15.9)
Valider un texte (registre de validation) content-validation.json : seule Malka (ou son mandataire) renseigne validatedBy et validatedOn de chaque clé La construction de production refuse les clés non validées
La durée de conservation des demandes de contact src/content/fr/legal.ts, champ privacy.retention (texte fourni par Malka) idem
L'adresse de réception des demandes Variable CONTACT_TO_EMAIL (si l'adresse affichée sur le site change aussi : site.contact.email et la valeur de .env.example, qui doivent rester égales) Redémarrer le conteneur (docker compose up -d)

18.9.3 Procédure de rotation de la clé d'API #

  1. Dans l'interface de Brevo, créer une nouvelle clé d'API (ne pas supprimer l'ancienne).
  2. Mettre à jour BREVO_API_KEY dans le fichier .env du serveur (ou dans l'interface de Coolify).
  3. Recréer le conteneur : docker compose up -d --force-recreate web.
  4. Vérifier : docker compose ps indique healthy ; envoyer une demande de test depuis le site ; vérifier la réception.
  5. Dans Brevo, révoquer l'ancienne clé.
  6. Consigner la date de rotation dans docs/go-live-report.md (jamais la valeur de la clé).
  7. Faire de même, au besoin, pour LOG_SALT : son changement ne casse rien et ne modifie que le pseudonyme des journaux futurs.

Fréquence recommandée : à chaque départ d'une personne ayant eu accès à la clé, après tout incident, et au moins une fois par an.

18.9.4 Checklist de remise à Malka #

À cocher dans docs/go-live-report.md ; chaque case est accompagnée de sa preuve.

  • Le site est accessible en HTTPS sur le domaine définitif ; http redirige vers https ; www redirige vers le domaine principal.
  • /api/health répond ok ; le conteneur est healthy ; le redémarrage automatique a été éprouvé.
  • Un envoi réel de bout en bout a été reçu en boîte de réception de astheoris.pro@gmail.com, l'accusé de réception a été reçu (AC-G-21).
  • Le lien WhatsApp de chacune des quatre offres ouvre une conversation avec le bon message prérempli.
  • Les prix affichés sont ceux validés par Malka ; la mention de TVA fournie par Malka est affichée.
  • Les mentions légales et la politique de confidentialité sont complètes et validées par Malka ; aucune mention « à fournir » ne subsiste.
  • Les textes marqués « proposition à valider par Malka » ont été validés (liste jointe) ou remplacés par les textes de Malka.
  • Les témoignages publiés ont un consentement daté ; sinon la section est absente.
  • Les photographies sont celles de Malka ; la licence du paysage est archivée dans docs/assets-licences.json.
  • Toutes les clés de content-validation.json sont validées par Malka ; la confirmation écrite du public (B2B ou B2C, M-22) est reçue.
  • Les captures du hero et du pied de page aux largeurs 390, 834 et 1440 ont été montrées à Malka et validées.
  • Les rapports docs/a11y-report.md, docs/perf-report.md, docs/go-live-report.md sont dans le dépôt.
  • Le README.md contient les 16 rubriques de 18.9.1 ; le guide d'édition de 18.9.2 est présent.
  • DECISIONS.md est à jour ; aucune dépendance n'a été ajoutée sans entrée.
  • La clé d'API de production est stockée uniquement sur le serveur ; la procédure de rotation a été lue et comprise par Malka ou son prestataire.
  • Le retour arrière (make rollback TAG=<étiquette précédente>) a été essayé et fonctionne.
  • Les accès (dépôt, VPS, Brevo, DNS) sont listés par rôle dans le rapport de mise en ligne, sans mot de passe ni clé.

Generated at GenerateSpecs.com — one idea in, one buildable spec out.

Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.