Malka Debleds — Entreprise Vivante Landing Page
Immersive French landing page for Malka Debleds' coaching practice, with a working contact flow and Docker deployment.
18,392 lines · 228,258 words · 20 sections · Oct 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 #
- Avant de commencer — décisions par défaut et éléments fournis par Malka
- Vision produit, objectifs et publics cibles
- Exigences fonctionnelles et non fonctionnelles
- Architecture UX, parcours et wireframes
- Design system
- Spécifications des sections et interactions
- Contenus éditoriaux (copy deck)
- Système d'animation Motion
- Architecture logicielle, modèle de contenu et contrats d'API
- Sécurité, RGPD et conformité
- SEO, accessibilité et performance
- Arborescence du projet et conventions de code
- Configuration et variables d'environnement
- Architecture Docker et déploiement
- Stratégie de tests et critères d'acceptation
- Plan de développement par étapes
- Risques, hypothèses, dépendances et arbitrages
- 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é :
- 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.
- Lire la section 18 (consignes à l'exécutant) avant d'écrire la première ligne de code.
- 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.
- 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.
- Lire les sections 10 et 11 (sécurité, RGPD, SEO, accessibilité, performance) et la section 14 (Docker et déploiement) avant le jalon correspondant.
- 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. - 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 :
lint,typechecket tests unitaires/intégration passent (commandes en §14 et §15).- Aucun avertissement React ni erreur de console au chargement de
/ni au parcours des interactions du composant. - 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).
- Le comportement
prefers-reduced-motion: reduceest vérifié (voir §8). - La navigation au clavier seul et l'analyse automatique d'accessibilité (axe) n'indiquent aucune violation de niveau sérieux ou critique.
- Les états de chargement, vide, erreur, succès et désactivé applicables sont implémentés (liste des états à couvrir par section en §6).
- 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 | 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 desrc/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 depnpm 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.tsest 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.jsonest entièrement renseigné par Malka (M-14, M-15, M-21, D-37) ; G-05 passe. - Chaque offre a
detailsrenseigné ;formatvaut « À distance » ; aucunedurationn'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-fallbackdans le build de production (G-02). - Portraits et paysage réels intégrés (
public/images/hero/etpublic/images/about/), détourage propre, aucun visage généré ou retouché par IA (M-01, M-02, M-04). -
docs/assets-licences.jsoncontient une entrée valide pour chaque visuel réel (G-08). - Image Open Graph
public/og/og-default.jpgen 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_KEYstocké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=falseen production (la valeurtruey 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 indiquespf=pass,dkim=pass,dmarc=pass. - Test d'échec simulé : sur la préproduction, avec
EMAIL_DRY_RUN=falseet 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 ;
wwwredirige en 301 vers l'apex (D-30). -
SITE_URLest l'URL HTTPS définitive (G-06) ; le certificat est valide et se renouvelle automatiquement. -
docker compose psmontrewebà l'étathealthy;curl -fsS https://<domaine>/api/healthrenvoie 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: reducevalidé (§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.txtautorise l'indexation etsitemap.xmlliste les trois URL canoniques ; aucune balisenoindexni en-têteX-Robots-Tagen 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"etdata-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.mdla 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) :
- Une écoute — un premier échange humain, sans engagement implicite.
- 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.
- 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'acceptationAC-3.2-nnouAC-3.3-nnvé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 |
3.2.8 J — Footer #
| 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→ repli100vh;mask-image/clip-pathnon 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égaux4.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 #
- Les identifiants sont en minuscules ASCII sans accent, séparés par des tirets, uniques sur la page.
- 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 (moduleanchors, emplacement en §12). Aucun composant n'écrit un#identifianten dur. - Un test automatisé (§15) échoue si un
href="#…"de la page ne correspond à aucunid, ou si unidd'ancre est dupliqué. Les repèresmethode-<id>sont des<span>vides distincts des onglets (methode-tab-<id>) : aucun identifiant n'est donc porté deux fois. scroll-margin-topde chaque section = hauteur du header + 16 px (88 px à partir de 1024 px, 80 px en dessous) ;scroll-padding-topde l'élémenthtmla la même valeur pour que le focus clavier ne soit jamais masqué par le header.- Les liens profonds
#methode-ecouter…#methode-decidersont écoutés (hashchange) : l'étape correspondante s'active, y compris au chargement initial de la page avec ce fragment. - 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 dearia-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. - Sur les pages légales, les liens du header deviennent
/#<ancre>; le logo mène à/; aucune mise en évidence de section active. - 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
#contactporte 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?offreune 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
tabindexpositif 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 detransformanimé 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) :
- 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-xlen varianteclamp(2.25rem, 7vw, 3.25rem). - La bande a un rayon de 20 px, une bordure champagne à 30 % d'opacité,
overflow: clip. Elle contient dans cet ordre : paysage aplatihero-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. - Les CTA sont en pleine largeur sur mobile (hauteur ≥ 52 px, espacement vertical ≥ 12 px) et côte à côte en tablette.
- 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é.
- 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. - 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) etsolid(fond plein dès le chargement, pages légales). Fond forêt-950 à 0,92 d'opacité sous 768 px ; à 0,85 avecbackdrop-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éemenu-mobile, plein écran, avec sa propre barre (logo et bouton de fermeture) ; boutonaria-expanded+aria-controls="menu-mobile";padding-bottom: max(32px, env(safe-area-inset-bottom)); piège de focus ;Échapferme et rend le focus au bouton ; pas de fermeture au clic sur le fond ; verrouillage du défilement de fond parhtml[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éefast; fermeture en fonduinstant; 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).
4.4.11 Footer « Passage & Clarté » (bloc J) #
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 #
- 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).
- Sur mobile et tablette, aucun texte n'est placé sur une image (D-20) : le voile n'est pas nécessaire.
- Sur ordinateur, la colonne de texte du hero repose sur le voile
--scrim-hero-x(valeurs deforest-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%
);
}- Le mot CLARTÉ est de couleur champagne-200 à plat, d'opacité 0,14 (hero) et, dans le footer,
ivory-50d'opacité 0,08 : texteivory-50posé sur la zone la plus claire possible du mot (forêtforest-900mé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. - 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-950d'opacité 0,80, jamais directement sur l'image. - 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 #
- 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 dehyphensforcés. - 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é.
- 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.
- 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. - 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). - 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). - 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 #
- 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.
- 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).
- 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.
- 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. - 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é).
5.4.5 Lueur de rivière (footer) #
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_OPACITYen section 8 ({ hero: 0.14, footer: 0.08 }). Un texteivory-50superposé au mot du hero (champagne-200à 0,14 surforest-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: clipsur la section (jamaisoverflow: hidden, qui casseraitposition: 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-xlest 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: nowrappar 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-numssurfigure,priceet le compteur de caractères ; si la police ne propose pas la fonctionnalité, le rendu reste correct.font-optical-sizing: autosur Fraunces ; aucunfont-variation-settingsmanuel.
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 :
- Le chargeur de contenu (
getContent, voir §9.3) appliquefrTyporécursivement à toutes les chaînes sauf les clés listées dansTYPO_SKIP_KEYS = ['id','href','url','src','email','phoneE164','whatsappE164Digits','slug','lastUpdated','alt']. Pour la cléquotedes témoignages, le chargeur appellefrTypo(text, { apostrophes: false, ellipsis: false }): le texte validé par Malka reste verbatim, seules les espaces avant la ponctuation haute sont rendues insécables.altest exclu uniquement parce que les textes alternatifs sont lus par les lecteurs d'écran : ils conservent des espaces normales. - Les messages issus de la validation (schéma Zod partagé, voir §9.4) et les libellés dynamiques sont passés par
frTypodans les composantsFormFieldetAlert. - 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. whatsappMessagetraversefrTypoau chargement comme tout autre texte ; le constructeur d'URLwa.me(§9.8) l'encode ensuite tel quel (U+00A0 →%C2%A0, U+202F →%E2%80%AF), sans jamais le modifier.- Un test unitaire
tests/unit/typography/fr.test.tscouvre 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)). - 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 #
- 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).
- 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).
- Aucune photographie de client, aucun faux portrait (voir §6.8).
- 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(avecsharpau runtime) génère à la demande AVIF en priorité, puis WebP, aux largeurs dedeviceSizes(voir §11.3.3). Les masters ne sont jamais recompressés avec perte avant dépôt. - 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"oudata-theme="light". - Posé sur l'élément de section (
<section data-theme="dark">), surheader/footer, ou sur n'importe quel sous-bloc qui inverse localement (cartefeatured, 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é parprefers-color-scheme(hors périmètre) ;color-scheme: lightest 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-*: initialsupprime la palette Tailwind par défaut : seules les couleurs du projet existent (ettransparent,current,inherit).- Les polices :
next/fontpose déjà--font-display,--font-display-italicet--font-sanssur<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 inlinepour 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 suitdata-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 :
- 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 dansglobals.css, dans la cartefeaturedet dans les composants qui fixent explicitement une couleur de marque (logo). - Les classes
rounded-*,shadow-*autorisées sont uniquement celles des tokens ci-dessus (la réinitialisationinitialfait échouer la compilation de toute autre classe de rayon/ombre non définie). - Les styles
hover:sont ceux de Tailwind (actifs uniquement sur appareils à survol réel,(hover: hover)) : aucun état de survol « collant » sur écran tactile. - 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 sousprefers-reduced-motionen 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;classNametoujours accepté en dernier recours via l'utilitairecn()desrc/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 natifdisabledpour 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'
opacityvarie ; aucune animation dewidth,height,margin,top,left(voir §8). Durées :fast(0,25 s) pour survol,instant(0,15 s) pour appui, courbeease-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 sousprefers-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/IconButtonportent la classebtn,ChoiceChipla classechip, les champs la classefield-control(utilisées par les règles de contraste élevé de la couchebase, §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.
5.12.1 Primitives de mise en page : Section, Container, VisuallyHidden, SkipLink #
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 |
5.12.2 Button et ButtonLink #
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.
5.12.4 NavLink #
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 :
- 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. - Un mot placé entre astérisques dans
title(*vivante*) est rendu<em>(italique de Fraunces ; couleur d'accent surh1/h2seulement, §5.11) ; un nombre impair d'astérisques fait échouer le test de contenu (§9.3) ; l'astérisque n'est jamais affiché. - Les masques de révélation (section 8) ajoutent
padding-block: 0.12em; margin-block: -0.12emaux 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. - L'état initial rendu côté serveur est visible (aucun
opacity: 0en HTML) ; l'animation n'est armée qu'après vérification d'hydratation (voir §8). align: centeruniquement 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 formulaireRègles :
- Aucune hauteur imposée : les offres n'ont ni hauteur égale ni alignement forcé (
align-items: stretchinterdit) ; 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'unVisuallyHidden« {montant} euros » pour la synthèse vocale (§11.2.10) ; « Sur devis » est lu tel quel, au styleprice, sans symbole monétaire. La durée (duration), si fournie, est ajoutée à unBadge; 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 offrepanelest 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 lignerowoucellaffiche, au survol et au focus interne, une barre verticale de 2 pxchampagne-400à gauche (détail en §6.5.5) ; le focus s'applique aux boutons et liens internes, jamais au bloc. Les actions suiventButton(§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 esthref="/?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. |
5.12.16 Composants de texte : TextLink #
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 44Wireframe < 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.tsexport 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-expandedpasse à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), fondforest-950opaque,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à0en duréefast; 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 :
useScrollLockpose l'attributdata-scroll-lockedsur<html>; la règlehtml[data-scroll-locked] { overflow: hidden; }est déclarée danssrc/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 utiliser100vwnulle part pour cette raison. - Fermeture : (a) Échap ; (b) bouton de fermeture ; (c) activation d'un lien ; (d) passage à ≥
xlpendant que le panneau est ouvert (écouteurmatchMedia('(min-width: 1280px)')) ; (e) changement depathnamesur 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'attributinertde#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, aurequestAnimationFramesuivant)target.scrollIntoView({ behavior })avecbehavior = 'auto'en mouvement réduit et'smooth'sinon,history.pushState(null, '', '#id'), puistarget.focus({ preventScroll: true })(la racine de section esttabIndex={-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: manipulationsur 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-50surforest-950, pill 44 px), textenavigation.skipLink(« Aller au contenu »), cible#contenu(le<main>,tabIndex={-1}). - Landmark :
<header>contenant<nav aria-label="Navigation principale">(valeurmenu.navLabel). Le panneau mobile contient son propre<nav aria-label="Navigation principale">(un seul landmarknavigationest exposé à la fois, car#app-rootest inerte pendant l'ouverture). - Liens actifs :
aria-current="location"(jamaispage). Sur les pages légales : aucunaria-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 LucideMenuetXenaria-hidden="true",aria-labelselonmenu.openLabel/menu.closeLabel,aria-expanded,aria-controls="menu-mobile". - Contraste : texte
ivory-50/ivory-100surforest-950≥ 15:1 ; en étattop, le dégradéforest-950/70garantit 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 utilisecurrentColor. - 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 etscroll-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) :
- 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 endisplay-xl(clamp(2.25rem, 7vw, 3.25rem)) ; une ligne peut passer sur deux lignes visuelles (pas denowrap). Sous 480 px, les boutons sont empilés en pleine largeur. - 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 aplatihero-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). - Chiffres de confiance, sur deux colonnes de largeur égale.
- Rail horizontal de quatre pas (voir « Rail de méthode »).
- 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/25relie quatre repères (cercle de 10 px). Chaque entrée : une capsule (pilule) de fondforest-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 enchampagne-400du premier repère jusqu'à cette entrée (transform: scaleY, origine haut) et le libellé resteivory-50avec 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 (anneauchampagne-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: smoothhors mouvement réduit,scroll-margin-topde 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 écoutehashchange, voir 6.4.6). Survol/focus : remplissage du filet jusqu'à l'entrée (≥ 1280 px). Tactile : pas de survol ; l'état:activecolore 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 conteneurdisplay: 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-xposé surchampagne-200plus 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 » sontaria-hidden. - Exergue : paragraphe
<p>avecclass="pull-quote"; guillemet décoratifaria-hidden. Il n'est pas dans un<blockquote>. - L'italique de la seconde ligne du titre est purement visuel : pas de
<em>(classe utilitaireitalicsur la police Fraunces italique chargée, §5.5.1). - Contrastes :
ink-900etink-600surivory-50≥ 7:1 ;champagne-600n'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 ») utiliseforest-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 toujoursaria-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 deMethodExplorer, avecdisplay: 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éfautecouterest 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 dehashchange). 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, sansaria-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,tabIndexitinérant,id. Nom accessible = libellé de l'étape (le numéro estaria-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 etaria-selected. - Focus visible sur chaque nœud et sur le panneau choisi (anneau
champagne-400, 2 px, décalage 2 px). - Contrastes :
ivory-100surforest-900≥ 12:1 ;champagne-200surforest-900≥ 9:1 ; bordure des nœuds au reposivory-50/40surforest-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 enHighlight, étiquette enHighlightText; bordures des nœuds visibles. - Les ancres
methode-<id>sontaria-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 boussolePanneau 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 :
- appelle
event.preventDefault(); - appelle
requestContactPrefill({ offerId }), qui : (a) écrit la demande dans le magasin en mémoire du module ; (b) émetCustomEvent('contact:prefill', { detail: { offerId } })surwindow; (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#contacten vue (scrollIntoView({ block: 'start', behavior }),behavior = 'auto'en mouvement réduit,'smooth'sinon ; le décalage d'en-tête est pris en charge parscroll-margin-top) ; (e) à la fin du défilement (événementscrollendquand 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 ; - é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-600surivory-100≥ 7:1 ;ivory-50/ivory-100surforest-900≥ 12:1 ; bouton WhatsApp (ivory-50surforest-800≥ 9:1 ;forest-950surchampagne-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 → offredéfinie dansorientation.ts. - Aucune persistance : pas de
localStorage,sessionStorage, cookie, ni paramètre d'URL pour mémoriser le choix. L'état vit uniquement dans unuseStatedu 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émaorientationSchema, §9.3.3) etsrc/content/fr/offers.tspour 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 situationwhatsappMessage: 20 à 300 caractères, commence par « Bonjour Malka », nomme la situation par sonlabelet 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 :
OrientationSectionappellegetBookingAction({ situationId })pour chacune des quatre situations (modulesrc/lib/booking/, serveur uniquement, voir §9) et transmet à l'îlotactions: Record<SituationId, { href: string; label: string }>. Le composant client n'importe jamaislib/booking/index.tset ne construit jamais l'URL lui-même. La fonction résout lewhatsappMessagede la situation, l'encode avecencodeURIComponentet renvoiehttps://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 toucher6.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 secondTabsort 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 :
Tabdepuis 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 portantgroupLabel. - 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ôneCheck+ changement d'épaisseur de bordure). - Panneau résultat :
<div id="orientation-result" role="region" aria-labelledby="orientation-result-title">. Il n'est pasaria-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ôneInfo(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", texteresultHeadings.reflection) ; blocs suivants enh4; 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 (
staggercartes 0,1 s) ; duréebase0,5 s, easingeaseClarte. - Changement de situation : le panneau résultat utilise
StableSwap(axey, distancexs, §8) — seul usage de ce composant avec le résultat de la boussole : sortie en opacitéfast0,25 s, entrée opacité 0→1 + translation Y 8→0 px,base0,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_clickaveccta: 'orientation_contact', sansoffer_idni 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 quandANALYTICS_PROVIDER=none.
6.6.10 Cas limites #
- Sans JavaScript (
@media (scripting: none)) : le bloc interactif est masqué etOrientationStaticListaffiche les quatre situations sous forme de liste statique (par situation : libellé enh3, piste de réflexion, questions, nom de l'offre suggérée avec lien vers#accompagnements, puis un lienmailto:et un lien WhatsApp génériques calculés côté serveur pargetBookingAction({})), suivie de l'avertissement global. Aucun<noscript>ni style en ligne. suggestedOfferIdinconnu : le build échoue (validation croisée §9.3) ; défensivement au runtime, la carte est omise (voir §6.6.5).- Retour arrière du navigateur, rechargement, changement de page puis retour : la sélection est perdue (comportement voulu, aucune persistance).
- 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é.
- Libellé de chip long (> 28 caractères) : refusé par le schéma ; au besoin le texte passe sur deux lignes sans tronquer.
- 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.
- Zoom 200 % et texte agrandi : aucun texte coupé, les chips se replient sur une colonne, le panneau passe sous le groupe.
- Mode contraste élevé (
forced-colors: active) : la cocheChecket la bordure2px(couleurCanvasText/Highlight) distinguent l'état sélectionné. - 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. - 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
#orientationcontient exactement unfieldset role="radiogroup"avec unelegendvisible et quatre radios natifs rendus dans le HTML serveur, nommés d'aprèsorientation.ts(idsdecision-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,
Tabn'effectue qu'un arrêt dans le groupe ; les flèches changent la sélection ;Espacecoche ; 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 duwhatsappMessagede la situation encodé ; il s'ouvre dans un nouvel onglet avecrel="noopener noreferrer"; le bundle client de l'îlot n'importe paslib/booking/index.ts. - AC-6.6-07 — Le bouton « Écrire via le formulaire » appelle
requestContactPrefill({ offerId, situationId }):#contactreçoit le focus, les listes « accompagnement » et « situation » du formulaire (§6.9) sont présélectionnées, le champ « message » est vide etlocation.hrefest 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,sessionStorageni 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 émettentcta_clickaveccta: 'orientation_contact'sansoffer_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 niscale, 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 ;
postureetapprocheen 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">;h2du titre ; blocs de posture et d'approche enh3.altdu portrait : description factuelle et neutre (texte en §7.17), jamais décorative et distincte de l'altdu portrait du hero ; le cadre champagne estaria-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-600surivory≥ 7:1 / ≥ 5,5:1 ; texteschampagne-600uniquement ≥ 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)slow0,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 (
staggerlignes 0,08 s),base0,5 s. - Mobile : pas de
clip-pathanimé, fondu simple.
6.7.9 Analytics (voir §9.8) #
Aucun événement dans cette section.
6.7.10 Cas limites #
- Un seul paragraphe fourni : accepté en développement et staging, refusé en production (minimum 2) ; Malka doit rédiger au moins deux paragraphes courts.
- Paragraphe très long (> 900 caractères) : refusé ; la limite préserve la lisibilité sur mobile.
- 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). - 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é,
altdifférent exigé). - 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.
- 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. - Registre
content-validation.jsonabsent ou clébiographynon 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-proposrend 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=productionavec la clébiographydu registrecontent-validation.jsonnon validée (ou moins de 2 paragraphes) fait échouerpnpm builden nommantbiography;about.tsne contient aucun champstatusnivalidatedOn. - AC-6.7-04 —
APP_ENV=productionavec un portrait référençantdev-fallbackfait é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
altnon vide de 10 à 140 caractères, différent de celui du portrait du hero ; le cadre décoratif estaria-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: aucunclip-pathanimé. - 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
paragraphsvide,bio.devPlaceholderest visible dans un encart distinct ; ce texte n'existe dans aucune page rendue avecAPP_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éeouEspace. - 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-ysur le viewport (le défilement vertical reste natif). Détection parpointerdown/pointerup(typestouchetpenuniquement) : déclenchement si|dx| ≥ 48 pxet|dx| ≥ 1,5 × |dy|;dx < 0→ suivant,dx > 0→ précédent ;pointercancelannule. 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
dragMotion (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 dearia-roledescriptionissues des clésroleDescriptions.*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, puisaria-live="polite"; elle contientliveRegion.slideChanged. Les panneaux eux-mêmes ne sont pas des régions live. - Point actif indiqué par
aria-currentet 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
tabindexsur 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,
base0,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
base0,5 s (easeSoft) ; le sortant passe à l'opacité 0 enfast0,25 s, puis reçoitvisibility: hiddenetinert(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 #
- Zéro témoignage publiable : section, lien de navigation, lien de pied de page absents ; l'URL
/#temoignagesn'a aucun effet (le navigateur reste en haut de page, aucune erreur). published:trueavecconsent.status:'pending'ou citation vide : refusé au build avec un message précis par entrée.- 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).
- Quote contenant des sauts de ligne : conservés (via
white-space: pre-line), pas de HTML interprété (échappement React). - Redimensionnement ou rotation de l'écran : la hauteur du viewport suit la grille CSS, l'index courant est conservé.
- Changement d'index pendant que le lecteur d'écran lit : la région polie attend la fin de la lecture en cours.
- 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.
offerIdréférence une offre retirée : le build échoue (validation croisée) ; défensivement, l'étiquette est omise.- Impression : toutes les citations publiables s'impriment l'une sous l'autre (
@media print: panneaux visibles, contrôles masqués). - Aucune donnée de consentement ni de date n'est affichée au public.
- 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 niid="temoignages", ni lien vers#temoignages(navigation, menu mobile, pied de page). - AC-6.8-02 — Avec une entrée
published:true,consent.status:'granted',grantedOnet 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"etinert, 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 unaria-labelissu decontrols.paginationAriaLabel; les flèches du clavier ne changent pas de citation. - AC-6.8-06 — Le conteneur est une
<section>portantaria-roledescriptionvalantroleDescriptions.carousel(« carrousel ») ; chaque panneau porterole="group"etaria-roledescriptionvalantroleDescriptions.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:truesans consentementgrantedfait échouerpnpm buildavec 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 etaria-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 degetBookingAction({}), appelé côté serveur (voir §9). - Textes :
src/content/fr/contact.ts(schémacontactSchema, §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 :
- Pictogramme
MessageCircle(aria-hidden) et titreh3(whatsappCard.title). - Texte court (
whatsappCard.text) : présente WhatsApp comme la voie la plus directe. Aucun délai de réponse, aucune promesse de disponibilité. - Bouton principal « Écrire sur WhatsApp » (
whatsappCard.ctaLabel) :<a>pilulechampagne-400(texteforest-950, contraste ≥ 8:1), hauteur 56 px,target="_blank" rel="noopener noreferrer", texte masquéwhatsappCard.ctaNewTabSuffix. Sonhrefvient degetBookingAction({})(message génériquewhatsappCard.defaultMessage). - Liste de coordonnées (
<ul>, icônes Lucidearia-hidden) :
| Ligne | Icône | Rendu | Lien |
|---|---|---|---|
| Téléphone | Phone |
06 67 29 51 79 (phoneDisplay, white-space: nowrap) |
tel:+33667295179 (phoneE164) |
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 :
- 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
offredewindow.location.searchparparsePrefill(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 parhistory.replaceState(null, '', location.pathname + location.hash); - s'abonne à
contact:prefillet applique chaque événement reçu ; - n'utilise ni
useSearchParamsni<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 :
- Une valeur inconnue, vide ou dupliquée est ignorée silencieusement (aucune erreur, champ inchangé).
- 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.
- Le préremplissage ne déplace pas le focus du formulaire (le focus va à la racine
#contact, §6.5.4) et ne soumet rien. - Le champ « message » n'est jamais prérempli ni modifié : aucun texte généré n'est inséré.
- Après application, la région live du formulaire annonce
prefillAnnouncement.byOffer({offerTitle}) et, le cas échéant,prefillAnnouncement.bySituation({situationLabel}). - 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"etdata-exclude-hash="true"(§9.8.2) : même le paramètreoffren'est pas transmis à l'outil de mesure. - 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'erreursSéquence de onSubmit :
event.preventDefault(). Sistatus === 'submitting'ousubmittingRef.current === true: retour immédiat (prévention de double envoi). Si un délaiRetry-Afterest actif : retour + rappel du message.- Valider ; si invalide : marquer les champs, afficher le récapitulatif, focus sur son titre, aucun événement analytics.
submittingRef.current = true;status = 'submitting'; viderfailure.- 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 enSubmitOutcome. - Dériver le
SubmitFailureduSubmitOutcomepar 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>portearia-busy="true". Les champs texte et le textarea passent enreadOnly; les selects et la case ignorent tout changement et portentaria-disabled="true"; il n'y a pas de<fieldset disabled>. - Bouton :
aria-disabled="true"(il reste focalisable), libellésubmit.loadingLabel, icôneLoaderCircleen rotation (CSS, 1 s, linéaire) ; sousprefers-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ésultataborted: 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) — émetcta_clickcontact_whatsapp. - Lien
failureBanner.emailLinkLabel:mailto:+site.contact.emailavecsubject=failureBanner.emailSubjectencodé ; 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()avecscroll-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: remetstatus = 'idle', valeurs initiales (offrenon-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-height640 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)dansglobals.css(aucun<noscript>, aucun style en ligne) : il contient le textenoscript.text, un lien WhatsApp (getBookingAction({})calculé côté serveur), un lienmailto:verssite.contact.emailet un lientel:verssite.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 pararia-describedby;aria-invalidà l'erreur ;aria-required="true"sur les champs obligatoires ; l'attribut natifrequiredn'est pas utilisé et le formulaire portenoValidate(la validation est celle du schéma partagé, pas celle du navigateur). autocompleteselon WCAG 1.3.5 :name,email,tel;offsur offre, situation, message et honeypot.- Message d'erreur identifiable en texte et par icône, jamais par la couleur seule ; contraste du token
errorsur 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, champsreadOnly). - Ordre du DOM = ordre visuel : la carte WhatsApp précède le formulaire à tous les breakpoints.
- État de chargement :
aria-busy, texte du bouton, annoncesubmit.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:etmailto: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 (
staggercartes 0,1 s),base0,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
fast0,25 s (aucune animation de hauteur ; le conteneur d'erreurs pousse le contenu : acceptable car déclenché par l'utilisateur). - Succès : fondu
base0,5 s de la confirmation ; icône de coche tracée parclip-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 #
- Visiteur qui revient sur la page après un succès : le formulaire est neuf (aucun état conservé).
- 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. - Collage d'un message de plus de 2 000 caractères :
maxLengthtronque ; le compteur affiche 2000 / 2000. - Plusieurs espaces ou message de 20 espaces :
trim→ message trop court. - E-mail en majuscules : accepté (le serveur le met en minuscules).
- Téléphone avec indicatif
+33: accepté ; conservé tel quel. - 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.
- Perte de connexion pendant l'envoi :
offline; à la reconnexion, l'événementonlineefface la bannière hors ligne (la saisie reste). - Navigation arrière pendant l'envoi : la requête est abandonnée (
abort) ; aucune erreur affichée. - Honeypot rempli par un gestionnaire de mots de passe : le serveur ignore silencieusement (succès affiché) ; les attributs
data-1p-ignore,data-lpignoreetautocomplete="off"réduisent ce risque. - Plusieurs onglets : chacun garde son état ; la limitation de débit serveur (§9.6) s'applique par adresse IP.
- Contenu enrichi / balises HTML dans le message : transmis tel quel, échappé côté serveur (§9.5) ; aucun HTML interprété à l'écran.
- 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 deSITE_URL(http://localhost:3000), et non par127.0.0.1ou l'adresse IP de la machine, sans quoi l'origine est refusée. prefers-reduced-motionactif : aucun spinner animé, transitions instantanées.- Section sans JavaScript : voir §6.9.13.
- 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. - Appel de
requestContactPrefillavant 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=…avectarget="_blank"etrel="noopener noreferrer";tel:+33667295179etmailto:astheoris.pro@gmail.comsont présents. - AC-6.9-04 — Chaque champ a un label visible, un
autocompleteconforme au tableau 6.9.6, et unaria-describedbyvalide ; 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,consentenaria-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 nidisplay:nonenitype="hidden"; axe n'y relève aucune violation. - AC-6.9-09 —
startedAtest 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 enreadOnly, bouton enaria-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.resetLabelré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 —
429avecRetry-After: 120afficheretryAfter.minuteManyavec{n}= 2 et rend le bouton inactif ; il se réactive à l'échéance. - AC-6.9-14 —
502affiche les liens WhatsApp et e-mail ; le lienmailto: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#contactprésélectionne l'offre puis réécrit l'adresse en/#contact; une valeur inconnue est ignorée sans erreur ;situationplacé 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_submitest émis une seule fois par tentative réseau ; jamais pour une erreur de validation locale ; émis avecresult: 'error'pour une réponse422du 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
fetchsuspendu au-delà de 30 s produit l'issuetimeout; avant cette échéance, l'interface reste en état d'envoi. - AC-6.9-22 — JavaScript désactivé (
@media (scripting: none)) : aucun formulaire visible ;ContactFallbackprésente WhatsApp, e-mail et téléphone ; le HTML ne contient aucun<noscript>ni style en ligne.
6.10 Footer « Passage & Clarté » (J) #
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 parFooterSection(voir §9) et transmis en props ; aucun îlot du footer n'importelib/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 :
- Le
<footer>est un contexte d'empilement (position: relative; isolation: isolate; display: flow-root) sansoverflow: hidden. Les décors (montagnes, rivière, mot géant) sont dans un enfantaria-hiddenposition:absolute; inset:0; overflow:hidden; z-index: var(--z-base)(clip interne), de sorte que la carte puisse dépasser du haut du footer. - 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-overlapau-dessus de la jonction Contact → footer, le reste est dans le fond vert. (display: flow-rootsur le footer empêche l'effondrement de la marge négative.) - La section précédente (
#contact) réserve la zone recouverte :padding-bottom: calc(var(--spacing-section) + var(--footer-overlap)). Comme--spacing-sectionvaut 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. - 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. - La carte a un fond opaque
ivory-50, un rayon de 20 px, l'ombre du tokenshadow-elev-4(§5.7) et une bordurechampagne-2001 px ; elle masque donc entièrement les décors derrière elle (aucun texte superposé à une illustration). - 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
paddingde 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-width6–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é,strokeblanc cassé (ivory-50),stroke-width3 px, filtrefeGaussianBlur stdDeviation="6"statique, opacité de base 0,35. L'animation shimmer (voir §8) fait varier uniquement l'opacityde#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 letransformd'un masque dégradé : aucune animation destroke-dashoffset. Rivière entièrement statique sur mobile (< 768 px) et sousprefers-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, couleurivory-50à 8 % d'opacité (WORD_OPACITY.footer),letter-spacing: 0.04em, aligné en bas, rogné par le bas du footer (décalagetranslateY(12%)). Il est statique (aucune parallaxe) et passe derrière la barre basse ; tout texte qui le recouvre garde un contraste ≥ 7:1 (ivoire surforest-950légèrement éclairci). Il n'est jamais lu par les lecteurs d'écran. - Fond du footer :
forest-950avec dégradé vertical versforest-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 buildCurrentYear (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.copyrightavec{year}interpolé parCurrentYear. - « Retour en haut » :
<a href="#haut">(cibleid="haut"de §4.2.1) avec icôneArrowUp(aria-hidden) ; fonctionne sans JavaScript ; défilement doux via CSSscroll-behavior: smoothsaufprefers-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-400en 1,5 px (transitionfast) ; boutons de la carte : classes.motion-press(appui 0,98) et.motion-arrowpour 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-4002 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égalfooter.columns.legal.title) ; les titres de colonnes sont des<p id>visibles, pas des titresh1–h6; chaque<nav>ou liste est étiquetée pararia-labelledbyvers ce<p>ou par sonaria-label(le plan de titres de la page reste inchangé).- Contraste :
ivory-50surforest-950≥ 15:1 ;sage-200(liens secondaires) ≥ 9:1 ;champagne-400sur 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-areane 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
slow0,9 s, translation Y 24 → 0 px), rivière (fonduslow, décalé de 0,2 s), carte (fondu + translation Y 16 → 0 px,base0,5 s), colonnes en cascadestaggercartes 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.FooterRiverrend toujours des élémentsm.*dont lesMotionValuerestent à 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 quanddocument.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 #
- 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#contactgrâce à la réserve depadding-bottom. - Contenu de
#contactplus long que prévu : rien à changer, la réserve est calculée à partir du bas de la section. - Très grand écran (≥ 1920 px) : montagnes en
slice, mot géant plafonné à 20 rem, conteneur plafonné à 1280 px. - Année : si l'horloge du visiteur est incorrecte, l'année affichée est celle de son système ; non bloquant.
- Impression : décors masqués (
@media print), carte CTA masquée, liens affichés avec leur URL pourtel:,mailto:. - Sans témoignage : la navigation reste cohérente (sept liens moins un).
- Si
getBookingActionne renvoie paswhatsapp(futur fournisseur) : le bouton principal de la carte suit l'action retournée (libellé et lien), sans changement de mise en page. - Lecteur d'écran : le mot « CLARTÉ » n'est jamais lu ; seul le contenu utile l'est.
- Mode contraste élevé (
forced-colors: active) : les décors sont masqués, la carte garde une bordureCanvasText.
6.10.11 Critères d'acceptation #
- AC-6.10-01 — Le footer est un unique
<footer>; il contient deux<nav>nommés et aucuniddupliqué 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 jamaisposition: fixedniposition: sticky. - AC-6.10-03 — Il existe au moins 64 px d'espace libre entre le contenu de
#contactet 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-hiddenet 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 sousprefers-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 émetcta_clickfooter_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> × ninterface 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àdevelopmentoustaging, toute valeur delegal.tsvide est remplacée dans la page par un repère<mark class="dev-missing">donnée manquante</mark>(fondchampagne-200, texteink-900, bordure pointilléechampagne-600). - Un bandeau
DevBanner(role="note", fondchampagne-200, bordure 2 pxchampagne-600, icôneCircleAlert, texteink-900≥ 7:1) est affiché en haut de la page, sous leh1; son texte estdevPlaceholderBannerde §7.13.1. Il énumère la liste des champs manquants (noms de clés delegal.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 siappEnv === '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
mediatorest absent : la sectionmediationn'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">;h1unique ; le « 404 » décoratif estaria-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
base0,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 constantesERROR_PAGE_COLORS = { bg, fg, accent }importées desrc/lib/design/tokens.ts(§5.1.3) : aucune couleur littérale n'est écrite dansglobal-error.tsx. PoliceGeorgia, serif, boutonaccentsurbg; 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 #
- 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.
legal.tsvide en développement : toutes les pages légales s'affichent avec bandeau et repères, sans planter.vatMentionouprivacy.retentionvide : en développement, repère « donnée manquante » ; en production, build en échec (§9.3).mediatorrenseigné sansurl: refusé au build (la paire nom + url est requise).- Données avec caractères spéciaux ou balises : échappées par React, aucun HTML interprété.
- URL inconnue sous
/api/…: réponse gérée par la route API (JSON, §9.4), pas parnot-found. - Erreur de rendu dans le layout racine :
global-error.tsxs'affiche avec une mise en page minimale mais lisible, colorée parERROR_PAGE_COLORS, et deux liens de sortie (réessayer, accueil). - 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. - Changement de
ANALYTICS_PROVIDERentre deux builds : la sectionaudienceapparaît ou disparaît sans autre modification. - Impression des pages légales : en-tête, footer et boutons masqués ; contenu en noir sur blanc.
- 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-legaleset/politique-de-confidentialiterépondent 200, sont générées statiquement et ont chacune un uniqueh1. - AC-6.11-02 — Avec les valeurs complètes dans
legal.ts, aucune mention « donnée manquante » ni bandeau n'apparaît ; chaque champ delegal.tslisté en 6.11.3 et 6.11.4 est affiché. - AC-6.11-03 — Avec
APP_ENV=developmentet un champ vide, le bandeau de développement est visible et liste le champ ; avecAPP_ENV=production,pnpm buildéchoue (garde de §9.3). - AC-6.11-04 — La section
audienceest absente du DOM avecANALYTICS_PROVIDER=noneet présente avecumami(contenant le nom d'hôte deUMAMI_SCRIPT_URLet le bouton d'opposition de §10.6.4). - AC-6.11-05 — La section
formulairecontient 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
idresponsable,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'oppositionumami.disabledécrit sur demande explicite du visiteur. - AC-6.11-07 — La section
whatsappdésigne WhatsApp comme service édité par Meta, soumis à sa propre politique ; la sectionreclamationcontient le lienhttps://www.cnil.fr/fr/plaintesavecrel="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 portenoindex. - AC-6.11-10 — Une erreur de rendu simulée affiche la page d'erreur ; « Réessayer » appelle
reset(); le focus est sur leh1; nierror.messageni 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.tsxrend<html lang="fr">avec styles en ligne, sans dépendance ànext/fontni à Tailwind, et importe ses couleurs deERROR_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.
- Espace fine insécable (U+202F) avant
;,!,?, et espace insécable (U+00A0) avant:: produites parfrTypoà partir d'une espace normale tapée dans la source. Exemple de source :Prêt ?; rendu à l'écran : « Prêt ? » (espace fine insécable). - 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») ;frTypola convertit en espace fine insécable à l'affichage. - Apostrophe typographique
’uniquement (jamais l'apostrophe droite'). Exemple : « aujourd’hui », « j’accepte ». Tapée directement dans la source ;frTypola corrige par défaut si elle est oubliée, sauf pour la citation verbatim d'un témoignage. - 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). - 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: nowrapen CSS, jamais par un caractère d'espacement spécial dans la chaîne. - Points de suspension : le caractère unique
…, jamais trois points.... Usage rare, réservé aux états de chargement (« Envoi en cours… »). - 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).
- 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/*.tsrespecte 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 desrc/content/fr/*.tsne contient de caractère U+00A0 ou U+202F écrit en dur — ces espacements sont produits parfrTypoau chargement (§5.5.6), jamais dans la source. AC-7.1-04 : aucune chaîne desrc/content/fr/*.tsne 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é
copyDeckdu registrecontent-validation.jsonn'a pasvalidatedByrenseigné etvalidatedOnvalide ; le texte biographique reste bloquant tant que la clébiographyn'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-currentporté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) etaria-controls="menu-mobile"; son libellé accessible change entreopenLabeletcloseLabel, l'icône seule n'est jamais le seul indicateur. - Le panneau mobile intercepte la touche
Échappour 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
valueetsuffixdesite.trustFigures;hero.tsne recopie pas ce texte. - Le rail vertical du hero réutilise les ids et libellés
Écouter,Explorer,Clarifier,Déciderdéfinis dansmethod.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 sousrailLabel(« Les quatre temps de la méthode ») porté par le conteneur du rail. - Le portrait de Malka n'a pas de texte dans
hero.tsau-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 dearia-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
headlineLinessont utilisées telles quelles par le composant de révélation, sans retraitement du texte. AC-7.4-02 : les deuxariaLabeldes 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
summarycompte entre 2 et 3 phrases, chaque tableaudetailscompte entre 2 et 4 éléments (3 pour les quatre étapes livrées), chaquewhatYouGetest une phrase unique — aucune case vide, aucun « … » de remplissage. AC-7.6-02 : les quatrelabel(« Écouter », « Explorer », « Clarifier », « Décider ») sont strictement identiques, caractère pour caractère, à ceux utilisés danshero.ts(§7.4) et dans les ancres#methode-ecouterà#methode-deciderdé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,detailsouwhatsappMessagene 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 :priceest 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 parformatPrice(§9.3.4), qui utiliseIntl.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 afeatured: true; les quatre valeurs deordersont 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
globalDisclaimerest 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) ; ledisclaimerpropre à chaque situation s'affiche en complément, jamais à sa place. AC-7.8-02 : chaquepromptcompte entre 2 et 4 phrases ; chaque tableauquestionscompte 2 ou 3 éléments ; aucunsuggestedOfferSentencen'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.bodyouapproach.bodyn'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.tsne 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 (
authorDisplayci-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_ERRORaffiche en priorité les messages de champ (validation.*, §7.11.3) issus deerror.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_LIMITEDcomplète son message par une durée d'attente lisible calculée à partir de l'en-têteRetry-After(secondes arrondies à la minute ou à l'heure supérieure,n = ⌈s/60⌉) : le texte ajouté provient deretryAfter.underMinute|minuteOne|minuteMany|hourOne|hourMany|unknown(§7.11.1), selon la durée restante.EMAIL_DELIVERY_FAILED,NETWORK_ERRORetTIMEOUTaffichent, en plus du message, les deux liens de repli (WhatsApp etmailto:astheoris.pro@gmail.com) déjà présents danswhatsappCard(§7.11.1) ;OFFLINEs'affiche sans lien de repli.METHOD_NOT_ALLOWEDne 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 queOFFLINE,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.
7.12 Footer « Passage & Clarté » #
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 :
copyrightinterpole 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 desite.tset du hero (§7.4) — une seule source de vérité réutilisée, jamais retapée différemment, jamais redupliquée dansfooter.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 delegal.ts, jamais une valeur écrite directement dans la copy ; la durée de conservation ({retentionPeriod}) provient delegal.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 lorsqueANALYTICS_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 lorsquelegal.ts → mediatorest renseigné. AC-7.13-03 : le bandeaudevPlaceholderBannern'est jamais rendu lorsqueAPP_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 caseconsent(§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.
digestLabeln'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.textTemplatene 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.textTemplatecouvre 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 gabaritNouvelle 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
titledes routes ci-dessus compte entre 20 et 60 caractères et chaquedescriptionentre 110 et 160 caractères, espaces et ponctuation compris ;routes.home.titlecontient à la fois « Malka Debleds » et « Entreprise vivante ». AC-7.16-02 :ogTitle/ogDescriptionsont définis pour la page d'accueil ; les autres routes réutilisenttitle/descriptioncomme valeurs Open Graph par défaut (pas de duplication de texte à maintenir). AC-7.16-03 :defaultOgImageAltdé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"ouaria-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
altrenseigné. 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 depuismotion/react. Le paquetframer-motionn'est pas installé. - Composants animés : toujours
m.*(jamaismotion.*), sousLazyMotionavec le jeu de fonctionnalitésdomAnimationet l'optionstrict(une utilisation demotion.*lève une erreur en développement). domMaxest interdit en v1 (il n'apporte quelayoutetdrag, 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) etframer-motionsont interdits par ESLint ; les compteurs utilisentrunTween(§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-motionest interdit à l'import ;- depuis
motion/react, les nomsmotion,domMax,domAnimation,animateetuseAnimationFramesont interdits,domAnimationn'étant autorisé que dans le fichier du fournisseur racine (src/components/layout/providers.tsx, §8.2.3) ; - aucun
addEventListener('scroll' | 'wheel' | 'touchmove' | 'mousewheel', …): utiliseruseScrollouIntersectionObserver(§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 :
domAnimationest importé de façon synchrone (pas defeatures={() => 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.MotionConfig reducedMotion="user"est un filet de sécurité : il neutralise automatiquement les animations de transformation si unm.*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.- La transition par défaut du
MotionConfig(base+easeClarte) est un repli ; chaque variant déclare sa propre transition. - CSP : Motion et les variables
--seq-*posent des styles en ligne (attributstylerendu côté serveur pour la séquence du hero). La politique de sécurité de contenu (§10) doit donc autoriserstyle-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.
- É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 parnext/image(preloadetfetchPriority="high": c'est la seule image du hero préchargée ; attributdata-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 portedata-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 enopacity(un élément àopacity: 0n'est pas candidat LCP). - 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). Sonopacitypasse 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) estopacity: 0; visibility: hidden: si l'animation CSS est supprimée, le voile ne recouvre rien (§8.3.5). - Titre
<h1>: présent dans le HTML serveur sous forme de lignes<span>masquées (overflow: hidden). Chaque ligne interne (data-seq="line") part detranslateY(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. - 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 fondforest-950du hero reste affiché même si l'image échoue. - Mesure obligatoire : un test Playwright (§8.8.4) relève l'élément LCP via
PerformanceObserveret 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 euopacity: 0.
8.2.7 Synchronisation avec le cycle de vie React #
- Effets et nettoyage : tout observateur (
IntersectionObserver), écouteur (animationstart,visibilitychange,changedematchMedia),requestAnimationFrameousetTimeoutcréé dans unuseEffectest libéré dans sa fonction de nettoyage. Les hooks doivent résister au double montage deReact.StrictMode(monter → démonter → remonter) sans fuite ni état incohérent. - Aucun
setStatepar frame : les valeurs animées passent par desMotionValue(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). onAnimationComplete: utilisé uniquement pour des tâches non critiques (remettrefilter: none, retirer un attributdata-*). Aucune logique métier (focus, soumission, navigation) n'en dépend.- 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.
- 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).
- Changement de préférence à chaud : si l'utilisateur active/désactive la réduction de mouvement pendant la visite,
useMotionProfile()se met à jour (abonnementchange), les animations en cours se terminent naturellement, les suivantes utilisent le nouveau profil. - 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). - 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-changestatique 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 proprestransition. - 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 :
Revealne se compose pas dans unStaggerGroup(utiliserStaggerItem) ; unRevealne contient pas d'autreReveal.delayn'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,
StaggerGroupn'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 ; aucunearia-labeln'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) viaparseInlineEmphasis; 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
fillest une boîteposition: absolutepleine 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 pourheroFar, 64 px pourheroMid, 24 px pourfooterFar, 12 px pourfooterMid, 0 px pourheroNear. L'image à l'intérieur est ancrée en bas (object-position: bottom). Le parent (hero, footer) porteoverflow: clipetisolation: 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: transformn'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émentsdata-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 seultransformchange). 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 dematchMedia.
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 :
- Rendu serveur : la valeur finale (
300+) est dans le HTML, dans deux éléments tous deuxaria-hidden="true": un fantôme (visibility: hidden, simple réserveur de largeur) et une couche visible qui porte la valeur affichée. Attributdata-count-final="300+"sur l'enveloppe. Le texte lu par les technologies d'assistance n'est pas produit ici : le composantTrustFigure(§5.12.7) l'ajoute dans unVisuallyHidden(« Plus de 300 personnes accompagnées »). Les lecteurs d'écran lisent donc toujours la valeur finale, jamais une valeur intermédiaire ; aucune régionaria-liven'est utilisée. - 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énementanimationstartde 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 ; sinon0puis décompte à l'entrée dans le viewport.
- Profils : reduced et
saveData→ valeur finale, jamais de décompte. lite → 700 ms. full → 900 ms (COUNT_UP). - Courbe :
easeOutExpo(1 − 2^(−10t)), proche deeaseClarte, sans dépendance Motion supplémentaire. - Mise en forme :
Intl.NumberFormat('fr-FR')(espace insécable fine pour les milliers),font-variant-numeric: tabular-nums(classetabular-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 ancresaria-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: trueest textuel : pas d'id, pas dename, pas de contrôle de formulaire. Les liens/boutons présents dans le résultat vivant sont absents ou neutralisés (inerts'en charge) dans le calque. - Le conteneur de
StableSwapn'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-nosnippetet 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'étatidleest 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,rootMargin0px 0px -10% 0px),scroll(valeur liée à la progression du scroll viauseScroll),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 :
- 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. - Règle P7 : chaque enveloppe
ParallaxLayer(Motion :transform) enveloppe un élémentdata-seq(CSS :transform,opacity) ; aucun élément ne porte les deux. - Le
<h1>n'est jamais enveloppé d'un composant client qui retarderait son rendu serveur ;heroText(gain à 0,y: [0, 0]) enveloppe unediv, pas le<h1>lui-même. Le texte ne bouge ni ne se fond jamais au défilement. - Les attributs
data-layout(famille :text,media,control,decor) servent aux tests de non-chevauchement globaux (§4.5.5).data-hero-boxen 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. - 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, horshero-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 ciblesaria-controlsvalides, 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 ouhashchange) : à 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:onPointerDownmémorise(x, y);onPointerUpcalculedx,dy; si|dx| ≥ 48 pxet|dx| ≥ 1,5·|dy|, passage au suivant (dx < 0) ou au précédent (dx > 0). Le conteneur portetouch-action: pan-y(le défilement vertical reste natif) ; aucun écouteurtouchmove/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 parRevealfadeUp. - Sans JavaScript, toutes les citations sont listées à la suite et les commandes sont masquées (§6.8, règle
scripting: nonede §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 enmin-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).
8.5.10 Footer « Passage & Clarté » (section J) #
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 #
- 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. - Profondeur d'orchestration maximale : section → groupe → élément (3 niveaux). Pas de
StaggerGroupimbriqué dans unStaggerGroup. - 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). - Durée maximale d'une révélation au scroll : 0,9 s (
slow). - Ordre : repère de section (étiquette) → titre → filet/texte → éléments interactifs.
- Aucun élément interactif (lien, bouton, champ) n'est rendu inerte ou invisible pendant qu'il s'anime : la cible reste cliquable et focalisable.
- 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).
- La section Témoignages absente (aucun témoignage publiable) n'ajoute ni code actif ni observateur : son lien de navigation n'existe pas,
useSectionObserverignore 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 #
- 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. - 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é ; lesParallaxLayerrestent montés mais leurs décalages sont à 0 (aucunwill-change). - 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.
- 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). - Économiseur de données : n'altère pas le chargement des images (réglé par
next/image, §11), uniquement le mouvement. - 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 #
- Aucun
will-changeglobal (*,section,.card, etc.) et aucunwill-changestatique dans les composants. will-change: transformest 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 àidleet la propriété disparaît (le navigateur libère la couche).- Les révélations (
Reveal,StaggerGroup,RevealLines) n'ont aucunwill-change: Motion/WAAPI promeut l'élément le temps de l'animation et le libère ensuite. - 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. - 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 #
- Aucun écouteur
scroll,wheel,touchmovenimousewheelécrit à la main. Les effets liés au scroll passent paruseScroll(parallaxe : appelé en permanence par chaque scène, sesMotionValuede décalage restent à 0 hors profil full) ouIntersectionObserver(révélations et leur filet de fin de document, section active, ambiances). Règle ESLint (§8.2.1) + test statique (§8.8.2). - Aucun écouteur
pointermove: il n'y a pas de parallaxe pointeur. - Aucune animation de layout : pas de
layout,layoutId,AnimatePresenceavecmode="sync"sur du contenu en flux qui pousserait d'autres éléments (le menu mobile est hors flux,position: fixed). - Aucune lecture de layout synchrone dans une boucle (
getBoundingClientRectn'est lu qu'une fois par élément à l'armement, une fois au gel de hauteur du formulaire, etscrollHeightuniquement dans le callback du filet de fin de document). - 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).
- Pas de
requestAnimationFramepermanent : seulrunTweenen utilise un, borné à 0,9 s. - Les images animées en
transform(paysage, portrait) sont des élémentsnext/image: aucune animation surbackground-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-motionou unimport { motion }depuismotion/reactest trouvé ; domMax,layoutId,layout=oudrag(prop Motion) apparaissent ;addEventListener('scroll',addEventListener('wheel',addEventListener('touchmove',onScroll=,onWheel=apparaissent ;will-changeapparaît ailleurs que danssrc/styles/motion.css(et seulement dans le sélecteur de scène active) ;- une clé
width,height,top,left,margin,padding,letterSpacingapparaît dans un objet de variant (variants.ts) ou dans unanimate={{ … }}; - une règle
transition:surcolor,background-color,border-color,box-shadow,widthouheightest trouvée dansmotion.css; Math.random()ouindexest utilisé commekeyd'un enfant d'AnimatePresence.
8.8.3 Tests de composants #
Reveal,StaggerGroup,RevealLines: rendu serveur (renderToString) sansstyleniopacity: 0; après montage avecIntersectionObserversimulé, l'état passessr→hidden→visible; le contenu reste dans le DOM à tout moment.- Aucun avertissement d'hydratation (
console.errorespionné) pour une page de test qui rend chaque primitive, avecmatchMediasimulé 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-selectedcorrect ; les quatre panneaux restent dans le DOM, un seul est visible (visibility) et sansinert; 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→ étapedecideractive, 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
ydu 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,
transformrestenoneet le rectangle de l'en-tête reste dans le viewport ; étatscrolledau-delà de 24 px, retour àtopsous 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.jsoncontient"packageManager"(pnpm, ligne de ce tableau) et"engines": { "node": ">=24 <25" };pnpm install --frozen-lockfileré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.yamlest commité ; le Dockerfile utilise--frozen-lockfile(§14). - AC-9.1-04 — Aucun fichier du dépôt, hors
package.json, le lockfile etDECISIONS.md, ne répète un numéro de version de dépendance (vérifié par un test de recherche textuelle dansREADME.md,src/etdocs/;DECISIONS.mdpeut 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 :
- 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é. - Une seule surface serveur dynamique :
POST /api/contact(etGET /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. - 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).
- 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 pargetBookingAction(serveur uniquement, §9.9) sont transmis aux îlots sous forme d'attributshrefen props : aucun îlot n'importesrc/lib/booking/index.ts. - Aucun îlot n'utilise
next/dynamicavecssr: 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 siteVariante 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-legaleset/politique-de-confidentialitecomme routes statiques, et/api/contactet/api/healthn'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 desrc/hooks/**n'importe un module serveur de la table du §9.2.4, nisrc/lib/content/**, nisrc/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.offersn'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 fournigetContent 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]etAuteur 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,aboutne 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 danssrc/lib/content/forbidden-claims.tset se complète par décision écrite. - Dérive de statistiques : le schéma n'accepte que les identifiants
accompagnesetformes; les valeurs doivent correspondre à la validation consignée pour la clétrustFiguresdu registrecontent-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']etDEFAULT_LOCALE = 'fr'sont les seules constantes à étendre. <html lang>est dérivé de la locale (DEFAULT_LOCALEau 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) dupliquersrc/content/fr/; (3) enregistrer les imports dansRAW; (4) introduire un segment[locale]danssrc/app, un fichierproxy.tspour la négociation, desalternates/hreflanget des entrées de sitemap par langue ; (5) passer la locale àgetContentdans 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 échouerpnpm buildavec 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
suggestedOfferIdinexistant est rejeté ; unofferIdde témoignage inexistant est rejeté. - AC-9.3-05 — Un témoignage
published: trueavecconsent.status: 'pending', sansgrantedOn, avecgrantedOnfutur ou mal formé ('2026-02-30'), avec une citation vide (ou ne contenant que des espaces), ou deux témoignages de mêmeid, 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),getPublishableTestimonialsretourne[],getRenderedSectionIdsn'inclut pastemoignages, et le HTML de/ne contient niid="temoignages"ni lien#temoignages. - AC-9.3-07 —
APP_ENV=productionaveclegal.tslivré (valeurs vides) : le build échoue, G-01 liste chaque champ vide ; avecAPP_ENV=development, le build réussit et les pages légales affichent le bandeau de développement. - AC-9.3-08 —
APP_ENV=productionavec une référencedev-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é parinternalHref; une chaîne contenant<script>, un caractère de contrôle ou U+202E est rejetée partext()et partextOrEmpty()(y compris danstestimonials.items[*].quote,testimonials.verificationNote, les champs delegal.tset lesaltd'image) ; la chaîne vide est acceptée partextOrEmpty()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,phoneE164etwhatsappE164Digitsincohérents sont rejetés ; un SIRET de 14 chiffres avec une clé de Luhn fausse est rejeté ;trustFiguresavec unidhorsaccompagnes/formesest rejeté. - AC-9.3-14 —
orientation.situations[*].promptd'une seule phrase ou de plus de quatre est rejeté ;questionsà 1 ou 4 éléments est rejeté ;globalDisclaimersans « diagnostic » est rejeté. - AC-9.3-15 —
APP_ENV=productionavec la clébiographydu registrecontent-validation.jsonnon validée, ou avec moins de 2 paragraphes dansabout.bio.paragraphs, échoue (G-05) ; endevelopment, un avertissement est imprimé sans interrompre le build ;aboutSchemane contient nibio.statusnibio.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
runnerdémarrée avecAPP_ENV=production, sanscontent-validation.json, sansdocs/et sans originaux, répond200à une soumission valide dePOST /api/contact: aucune garde de production n'est exécutée au runtime etgetContentne lit aucun fichier. - AC-9.3-18 —
legalSchemaexige la cléprivacy.retention;APP_ENV=productionavecprivacy.retentionvide échoue (G-01) ; aucune valeur par défaut n'est livrée. - AC-9.3-19 —
PLACEHOLDER_PATTERNrejette[FIXTURE]etAuteur FixtureenAPP_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
200avec lerequestIdde la première et le journaloutcome: 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
502ne 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 :
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.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).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 estserver_errorsanscode, avec le messageINTERNAL_ERROR.Correspondance :
200+ok:true⇒success;422⇒validation(avecfields) ;429⇒rate_limited(retryAfterSecondslu dansRetry-After, 60 par défaut, borné à [1, 86 400]) ;502⇒delivery_failed;400,403,405,413,415,500et tout autre statut ⇒server_erroravec lecodede l'enveloppe et lehttpStatus;TypeErrordefetch⇒network_error; abandon par le délai ⇒timeout; abandon par l'appelant ⇒aborted.Les messages affichés proviennent de
error.messagedu serveur ou, pournetwork_errorettimeout, decontact.apiErrors.NETWORK_ERRORetcontact.apiErrors.TIMEOUT; l'état « hors connexion » (navigator.onLine === false) utilisecontact.apiErrors.OFFLINE(§9.3.3).Dérivation du type d'échec affiché (table unique ; la présentation de chaque type est décrite en §6.9) :
SubmitOutcomeType d'échec de l'interface validationvalidationrate_limitedrate_limiteddelivery_faileddeliveryserver_error+PAYLOAD_TOO_LARGEtoo_largeserver_error+FORBIDDEN_ORIGINforbidden_originserver_error+INVALID_JSON,UNSUPPORTED_MEDIA_TYPEouMETHOD_NOT_ALLOWEDtechnicalserver_error+INTERNAL_ERRORou sanscodeinternalnetwork_erroravecnavigator.onLine === falseofflinenetwork_erroren lignenetworktimeouttimeoutRègles de
startedAt(propriétaire : cette section). (a) Capture : à la première interaction (focusinouinput) avec le formulaire,startedAt= instant client de cette interaction +offsetMs(règle b) ; remis ànullpar « Envoyer un autre message ». (b) Recalage d'horloge : au montage du formulaire (après 1 s d'inactivité,setTimeout),calibrateClock()émetHEAD /api/health(délai 2 s) et calculeoffsetMs = 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èsstartedAt(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 destartedAtdépasse 20 h à la soumission, il est recalé surmaintenant + offsetMs − 10 000 ms, pour rester dans la fenêtre acceptée par le serveur (3 s à 24 h).Le wrapper ne journalise rien (ni
console.logdu payload, ni des champs).
Critères d'acceptation 9.4
- AC-9.4-01 — Une requête valide produit
200 {ok:true,data:{requestId}}avecrequestIdUUID v4, l'en-têteX-Request-Ididentique, 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
Originrenvoie403(le corps n'est pas lu) ; un JSON invalide sansOriginrenvoie403; avec unOriginvalide, un corps de 20 Ko renvoie413et un JSON invalide renvoie400. - AC-9.4-04 —
Originabsent ⇒ 403 ;Origin: https://evil.example⇒ 403 ;Sec-Fetch-Site: cross-siteavec bonOrigin⇒ 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éponse200 ok:true, aucun appel à l'e-mail, journaloutcome:"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
429avecRetry-Afterentier ≥ 1 ; les requêtesspam_droppedetvalidation_failedne consomment pas les fenêtres « acceptées ». - AC-9.4-07 — Échec du fournisseur d'e-mail ⇒
502 EMAIL_DELIVERY_FAILEDet 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éponse200part sans attendre l'accusé, même si le fournisseur est lent ; un échec de l'accusé produit le journalcontact.ackoutcome:"ack_failed"(qui peut suivrecontact.request) sans effet sur la réponse ; l'accusé portereplyTo=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,OPTIONSsur/api/contact⇒405avecAllow: POSTet enveloppe JSON ; aucun en-têteAccess-Control-*n'apparaît jamais. - AC-9.4-11 — Les messages d'erreur sont en français et n'incluent jamais la valeur saisie ;
fieldsne contient que des clés deContactFieldKey(§9.4.2). - AC-9.4-12 —
GET /api/healthrépond200en moins de 100 ms sans aucun appel réseau (test avecfetchinterdit), avecCache-Control: no-store. - AC-9.4-13 — Le wrapper client ne relance jamais ; un
fetchsuspendu au-delà de 30 s produittimeout; une réponse HTML502d'un proxy produitserver_errorsanscode; un413produitserver_erroraveccode: 'PAYLOAD_TOO_LARGE'ethttpStatus: 413. - AC-9.4-14 — Un nom contenant
\r\nou U+202E, un e-mail contenant\n, un message de 3 liens, unconsentvalant"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.subjectTemplateettextTemplatede §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 partoSingleLine(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 parnl2br. 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 (aucunmailto:nitel: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é partoSingleLine.
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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
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 & b devient a &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_ADDRESSsur 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_FAILEDavecproviderStatus:401).
Critères d'acceptation 9.5
- AC-9.5-01 — Le test de la charge utile Brevo contient
sender,to,subject,htmlContent,textContent;replyTovaut l'adresse du visiteur pour la notification et l'adresse publique de Malka (site.contact.email) pour l'accusé ; l'en-têteapi-keyest présent et absent de toute sortie de journal. - AC-9.5-02 — Sur
503puis201, l'adaptateur fait exactement 2 appels espacés d'au moins 500 ms ; sur400, exactement 1 appel ; sur401, 1 appel etkind: '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<scriptet contient<script>; 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\rni\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,
fetchn'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
hrefousrcdu 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.entriesProprié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): siTRUST_PROXY=true, prend la dernière valeur deX-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éunknownest utilisée ; siTRUST_PROXY=false, l'en-tête est ignoré et la cléunknownest 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 danssrc/lib/rate-limit/memory-sliding-window.ts, fabriquegetRateLimiter()danssrc/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_PROXYdoit valoirfalse(tous les visiteurs partagent alors le seauunknown).
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
websiteest un<input type="text">hors flux visuel (positionné hors écran, jamaisdisplay:noneseul),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êtePOSTqui 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
consumepour une même IP en 10 min sont acceptés, le 6ᵉ est refusé avecretryAfterSeconds = 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 —
consumeest atomique : un refus surglobal_1hn'incrémente pasaccepted_10m;refundrestitue 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=trueavecX-Forwarded-For: 198.51.100.9, 203.0.113.7⇒ IP203.0.113.7(dernière entrée) ; avecX-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éponse200. - AC-9.6-11 — Lorsque
accepted_10m(libération dans 3 min) etaccepted_24h(libération dans 6 h) refusent simultanément, la décision porterule: 'accepted_24h'etretryAfterSeconds = 21 600:Retry-Afterest 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?, … }avectsen ISO 8601 UTC. - Seuil :
LOG_LEVEL(debug<info<warn<error), défautinfo. - Les clés hors liste blanche sont supprimées ; en test (
NODE_ENV=test) elles lèvent une erreur pour détecter l'oubli. errMessagen'est renseigné que pour des erreurs internes, aprèsscrub(): 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 : pourContactForm, les liens WhatsApp, téléphone et e-mail desite.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) etglobal-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érenceerror.digestest affichée pour permettre à l'exploitant de retrouver l'événementserver.errorcorrespondant.- Les rejets de promesses dans les gestionnaires d'événements (hors rendu) sont capturés localement (
try/catch) : un échec detrack()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.infolève une erreur en test et est supprimé en production. - AC-9.7-04 —
LOG_LEVEL=warnsupprime les lignesinfo. - AC-9.7-05 — Une exception volontaire dans un composant serveur produit un événement
server.erroravecdigestet sans message contenant un motif d'e-mail. - AC-9.7-06 — Une exception levée dans
OrientationCompassaffiche le repli sans casser le reste de la page ; une exception dansContactFormaffiche les liens WhatsApp, téléphone et e-mail. - AC-9.7-07 — Au démarrage avec un environnement de production invalide,
app.env_invalidest é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.
9.8.4 Adaptateur track() et composant CtaLink #
// 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>avecdata-website-id,data-do-not-track="true",data-auto-track="true",data-exclude-search="true",data-exclude-hash="true"et la sourceUMAMI_SCRIPT_URL. - AC-9.8-03 — Un clic sur chaque
CtaLinkappellewindow.umami.track('cta_click', …)avec exactement les propriétés du tableau ;offer_idn'est présent que pouroffer_whatsappetoffer_form. - AC-9.8-04 —
track('cta_click', { cta:'orientation_contact', situationId:'fatigue', offer_id:'lecture-globale' })n'envoie nisituationIdnioffer_id, et aucun paramètre d'URL d'orientation n'est transmis à l'outil de mesure. - AC-9.8-05 —
contact_submitn'envoie queresult;method_step_openn'envoie questep(valeur hors des 4 identifiants rejetée). - AC-9.8-06 — Un événement hors catalogue est ignoré ;
window.umamiindéfini ne lève aucune exception. - AC-9.8-07 — Un clic sur un
CtaLinkn'empêche jamais l'ouverture du lien, même sitrack()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 contientlecture-globale,offreni#contact; l'URL de la page après chargement ne contient plus le paramètreoffre(§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.meRé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 :
- Fournisseur : ajouter
'calcom'àBookingProvider; ajouterCalcomBookingAction { kind:'calcom'; href: string; label: string; mode: 'link' | 'embed' }à l'unionBookingAction. - Configuration : ajouter la variable de build
CALCOM_LINK(URLhttpsde la page de réservation) au schéma d'environnement et au tableau de §13 ; sa présence active le fournisseur (getBookingProvider(env)). - Fonction : étendre
getBookingActionpar unswitch (provider); ajoutergetBookingActions(input): { primary: BookingAction; secondary?: BookingAction }(Cal.com en principal, WhatsApp en secondaire) — l'API existante reste valide. - 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.tsxchargé à 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. - Sécurité et conformité : pour
mode:'embed', ajouter l'origine de Cal.com àscript-src,frame-srcetconnect-srcvia le générateur de CSP (§10.3), documenter ce sous-traitant dans la politique de confidentialité (legal.tset §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 variantelinkdoit être retenue. - Analytique : ajouter
offer_calcom(etcontact_calcom) àCTA_IDS(§9.8.3), sans propriété supplémentaire. - Tests : étendre les tests de §15 (action retournée, absence de requête avant clic, CSP).
- Rien d'autre ne change : les cartes d'offre, la boussole et le footer continuent d'appeler
getBookingAction; seul le contenu du bouton (leBookingActionreçu) évolue.
Critères d'acceptation 9.9
- AC-9.9-01 — Pour chaque offre,
getBookingAction({ offerId })retourne{ kind:'whatsapp', href, label }avechrefégal àhttps://wa.me/33667295179?text=${encodeURIComponent(offer.whatsappMessage)}calculé indépendamment (le numéro vient desite.contact.whatsappE164Digits). - AC-9.9-02 —
getBookingAction({ situationId })utilise le message de la situation ;getBookingAction({ offerId: 'inconnu' })etgetBookingAction({})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înewa.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 àencodeURIdanssrc/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 #
- 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).
- Défense en profondeur : chaque risque est traité par au moins deux couches indépendantes (validation, limitation, en-têtes, isolation du conteneur).
- É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é.
- 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-requestsAvec 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 -Isur/,/mentions-legales,/api/healthet un fichier statique renvoie les sept en-têtes de §10.3.1 avec les valeurs exactes (HSTS seulement avecSITE_URLenhttps:max-age=31536000; includeSubDomainsen production,max-age=300en préproduction). - AC-10.3-02 — Sans analytique, la CSP ne contient aucune origine hors
'self'; avec Umami, l'origine du script apparaît dansscript-srcetconnect-srcuniquement. - 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émahttp:;buildHeaderRulesest l'unique producteur de règles d'en-têtes (aucunheaders:dansnext.config.tshors de l'appel au constructeur, aucunheaderdans 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-filesne contient aucun fichier.env(hors.env.example) ;.env.examplene contient aucune valeur pourBREVO_API_KEYetLOG_SALT. - AC-10.4-02 —
docker historyde l'image finale ne montre niBREVO_API_KEYniLOG_SALT. - AC-10.4-03 —
docker runde l'image sansBREVO_API_KEYavecAPP_ENV=productions'arrête avec un message nommant la variable (sans valeur). - AC-10.4-04 — Le contrôle
git grepdes 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=highet é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.mdcontient 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,
localStoragenisessionStorage. 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.cookieest vide, quelocalStorageetsessionStoragesont 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) :
- 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.
- Aucun identifiant persistant déposé ou lu sur le terminal (pas de cookie, pas de stockage local) ; Umami utilisé dans sa configuration sans cookie.
- 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.
- 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.
- 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'oppositionAnalyticsOptOut. - 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_EMAILpour 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,
publishedpasse àfalseet 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-generalesest 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.mediatorest 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") ;noreferreret la politiquestrict-origin-when-cross-originlimitent 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.contactuniquement.
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) #
- 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.
- Mettre à jour
BREVO_API_KEYdans l'environnement d'exécution : fichier.envdu serveur (permissions600) ou variable « runtime » de Coolify (jamais « build »). - Recréer le conteneur pour relire l'environnement :
docker compose up -d --force-recreate web(ou « Redeploy » dans Coolify). - Vérifier :
GET /api/healthrépond 200 ; envoyer une demande de test depuis le formulaire public ; la notification arrive dans la boîte de Malka ; le journal contientemail.sendavecoutcome:"ok". - Révoquer l'ancienne clé dans Brevo et vérifier qu'aucun envoi ne l'utilise encore.
- Effacer la clé des supports temporaires (historique de terminal, notes) ; ne jamais la coller dans un message.
- 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.
- Faire tourner
LOG_SALTlors 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.cookieest vide,localStorageetsessionStoragesont 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 ; avecnone, ni l'un ni l'autre ; le bouton bascule bienlocalStorage['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 verswa.men'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 deLOG_SALTet la gestion d'une fuite avérée. - AC-10.10-02 —
SEND_ACKNOWLEDGEMENT=falsesupprime 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 #
- 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. - 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, balisemeta robotset en-têteX-Robots-Tag(§11.1.7). - 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.
- 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 pargetBuildEnv()(§13.4) : suppression de la barre oblique finale ; en production, schémahttps:, hôte nilocalhostni adresse IP (sinon le build échoue avec un message explicite).src/lib/seo/site-url.tsne lit donc aucune variable d'environnement directement : il appellegetBuildEnv(). Le sous-domainewwwn'est pas utilisé dansSITE_URL(la redirectionwww→ 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
metadataBasedu layout racine vautnew 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 parstripFrTypo, §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-srcde 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 dehreflangni dealternates.languagesau lancement (le français est la seule langue ; la structuresrc/content/fretgetContent(locale)préparent l'extension).- Icônes :
src/app/icon.svg(monogramme « M » en Fraunceschampagne-400sur carré arrondiforest-900, composition purement typographique, sans visage) etsrc/app/apple-icon.png(180 × 180) ; pas de manifeste d'application web. - Les pages
/mentions-legaleset/politique-de-confidentialitesont indexables et liées depuis le footer ;/politique-de-confidentialitementionne l'analytique uniquement si elle est activée (§10). - Les redirections
http→httpsetwww→ apex sont des 301 gérées par le proxy (§14) ; l'application ne fait aucune redirection d'hôte. Content-Language: frn'est pas ajouté (l'attributlangsuffit).
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/playwrightavec les baliseswcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa, exécutés dans chaque état interactif listé en §11.2.12, avec comme seuil bloquant 0 violation d'impactseriousoucritical(les violationsmoderateetminorsont corrigées ou consignées et justifiées dansdocs/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) :
- Lien d'évitement (visible au focus) ;
- Logo (→
#accueil) ; - Liens de navigation (Philosophie, Méthode, Accompagnements, Orientation, À propos, Témoignages si présent, Contact) ;
- CTA « Faire le point » (dans la barre dès 640 px ; sous 640 px, dans le menu mobile) ;
- Hero : « Prendre rendez-vous », « Découvrir la méthode », puis liens de la barre de méthode (Écouter, Explorer, Clarifier, Décider) ;
- Philosophie (liens éventuels) ;
- Méthode : onglet sélectionné, puis panneau ;
- Accompagnements : par offre, résumé du détail, bouton « Écrire sur WhatsApp » puis lien « Écrire via le formulaire » ;
- Orientation : groupe de situations (un seul arrêt), puis les deux CTA du résultat (« Écrire sur WhatsApp », « Écrire via le formulaire ») ;
- À propos (liens éventuels) ;
- Témoignages : bouton précédent, points de navigation, bouton suivant ;
- Contact : bouton WhatsApp, champs dans l'ordre visuel, case de consentement, bouton d'envoi ;
- 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 diapositiverole="group"aria-roledescription="témoignage"aria-label="{i} sur {n}". Les valeurs dearia-roledescriptionsont en français (la page estlang="fr"). Toutes les diapositives restent montées dans une même cellule de grille ; les diapositives non courantes sontinertetvisibility: 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 » (
IconButton48 × 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,politeaprè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 sipublishedet 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 :
- « CLARTÉ » géant :
aria-hidden="true", hors de l'arbre d'accessibilité. - Téléphone : lien
tel:+33667295179dont le texte affiché est « 06 67 29 51 79 » avec des espaces simples entre les paires de chiffres (lecture par paires) ; le lien portewhite-space: nowrappour ne jamais être coupé ; aucunaria-labelqui remplacerait le texte visible. - Prix : le montant mis en forme (
1 000 €) estaria-hidden="true"et unVisuallyHiddenporte « 1000 euros » (évite la lecture « 1 » « 000 » selon la synthèse vocale) ; « Sur devis » est lu tel quel. - Chiffres de confiance : « Plus de 300 personnes accompagnées » (§5.12.7).
- 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
frTypopour la ponctuation (modification unique, centralisée). - Ellipse : le caractère
…est lu « points de suspension » ; conservé. - 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. - Adresse e-mail :
mailto:avec texte visible = adresse ;overflow-wrap: anywhere. - 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) :
/au chargement ; 2. headerscrolled; 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 #
/,/mentions-legales,/politique-de-confidentialitesont 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 desrc/content/fr(§9.3).- 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). - Aucun appel réseau côté client au chargement ; la seule requête applicative est le
POST /api/contactà l'envoi du formulaire. - 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 degetImageProps(hero-far-layer.tsxethero-landscape.tsx, §12.2) sont les seules exceptions à l'interdiction de<img>brut : la règle@next/next/no-img-elementy est désactivée par un commentaire justifié (les attributs de l'<img>proviennent degetImageProps). Les calqueshero-mid,hero-nearet la bandehero-mobilesuivent le même schéma quehero-farsans préchargement nidata-lcppropre (la bande portedata-lcp="hero-band"). - Aucun flou de substitution (
placeholder="blur") : les conteneurs ont la couleur de fond du slot (forest-950pour le hero ; dégradéivory-200→sage-200pour 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-ratioou 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 portedata-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 jamaisopacity: 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 scriptscripts/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/&/\&/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
done11.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 instancenext/font,preload: false, variable--font-display-italic) et Manrope (variablewght). Manrope n'a pas d'italique etfont-synthesis: noneest posé surhtml(§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
woff2des deux familles de base (Fraunces romain et Manrope), générés automatiquement parnext/fontdans<head>. Le sous-ensemblelatin-extest 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'unemde titre ou une citation d'accroche est rendu ; son poids est consigné dansdocs/perf-report.mdet le CLS mesuré inclut son chargement. Si le cumul préchargé dépasse 200 Ko au premier build, supprimerlatin-extdes déclarations desrc/app/fonts.ts(le français n'en dépend pas) et consigner la mesure dansdocs/perf-report.md. - Les polices sont déclarées dans le layout racine (une seule fois) ; aucun
@font-facemanuel. - 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/dynamicavec rendu serveur conservé. - Le fournisseur de mouvement (
LazyMotion+MotionConfig reducedMotion="user") chargedomAnimationde 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 :
m.*(Motion) uniquement ; interdiction d'importermotion.*hors du fournisseur ;domAnimationsuffit (voir §8).- Icônes
lucide-reactimportées nommément (arborescence élaguée) ; aucun import de l'index complet. - 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/**nisrc/lib/booking/index.ts: les textes et leshref(dont WhatsApp) arrivent en props depuis les composants serveur. - Pas de bibliothèque utilitaire lourde (dates, lodash, animation concurrente).
- 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 #
- Une feuille Tailwind unique, purgée par le compilateur (aucun CSS mort), ≤ 35 Ko gzip. Aucun CSS-in-JS d'exécution, aucun
@importexterne. - Tout CSS animé se limite à
transform,opacity,filter(≤ 8 px, desktop) etclip-path(voir §8). Aucune animation dewidth,height,top,left,margin,box-shadow. will-changen'est jamais écrit en CSS statique ; Motion le pose et le retire pendant les animations.- Les calques décoratifs ont
contain: layout paintetpointer-events: none. backdrop-filter: uniquement le header scrollé à partir de 768 px (§5.4.3).content-visibility: autoest 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.- Pas de grandes zones de
filter: blur()fixes ; les halos sont des dégradés radiaux. - Interactions tactiles :
touch-action: manipulationsura, 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) #
- Toute image a
width/heightou un conteneur avecaspect-ratio; aucun conteneur d'image sans taille intrinsèque. - À partir de 1024 px, le hero utilise
min-height: var(--hero-min-h)=clamp(720px, 100svh, 1080px)(unitéssvh, jamaisvh, §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 unaspect-ratiofixe (16:10 de 768 à 1023 px, 9:10 en dessous de 768 px). - Le header a une hauteur constante (72/64 px) dans tous ses états ; seul le fond change.
- Polices : métriques de repli ajustées (§11.3.4) ; pas de
font-display: optional; chiffres tabulaires pour les valeurs animées. - 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.
- Le slider empile toutes ses diapositives dans une même cellule de grille (les diapositives inactives sont
visibility: hiddenetinert) : la hauteur est celle de la plus haute ; le changement de diapositive n'altère pas la hauteur. - La boussole de méthode empile ses quatre panneaux de la même manière (inactifs
visibility: hiddenetinert) ; le changement d'onglet est un fondu (opacité) sans changement de hauteur. - La boussole d'orientation réserve la zone de résultat (
min-heightcorrespondant 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. - 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).
- 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). - Pas de
lazysur une image au-dessus de la ligne de flottaison ; pas d'image sans réservation sous la ligne de flottaison. scrollbar-gutter: stablesurhtml: le verrouillage du défilement (html[data-scroll-locked]) à l'ouverture du menu ne décale pas la page.- Les polices ne sont jamais chargées par CSS différé.
- Les éléments dont le contenu change (compteur de caractères, chiffres de confiance) ont une largeur minimale en
ch. - La carte flottante du pied de page chevauche
#contactde--footer-overlapdans une zone réservée (padding-block-endde#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) #
- Aucun gestionnaire d'événement ne dépasse 50 ms de travail synchrone ; tout travail non essentiel est différé (
startTransition,requestIdleCallbackavec replisetTimeout). - Aucun
setStatedéclenché par le défilement : la section active est calculée par unIntersectionObserverunique (seuils fixes) ; la parallaxe utilise des valeurs de mouvement (useScroll,useTransform), sans re-rendu React. - Les écouteurs
scroll,touchstart,touchmovesontpassive; pas de lecture de géométrie (getBoundingClientRect,offsetHeight) suivie d'écriture de style dans la même boucle. - 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,
keystables). - Le formulaire gère l'état localement, valide à l'envoi et au
blurdes champs modifiés (§5.12.12) ; la validation Zod d'un champ est synchrone et < 5 ms. - 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. - Contrôle automatisé : test Playwright avec ralentissement CPU ×4 (CDP) mesurant, via
PerformanceObserverde typeevent, 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. - 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/scriptenafterInteractive, attributdefer,data-do-not-track="true",data-exclude-search="true"etdata-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 ; aucunpreconnect; 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)://danssrc/). Liste blanche explicite : lienswa.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éveloppementhttp://localhost:3000des modules de configuration.
11.3.11 Plan de mesure et configuration Lighthouse CI #
Mesures :
- 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'outillageLHCI_BASE_URL(§13) et la passe en--collect.url. - CI : mêmes assertions bloquantes ; test Playwright
tests/e2e/perf-budget.spec.ts(§15) qui, sur/en mobile : somme les octets gzip de tous lesscript(tailles compressées parzlibsi l'en-têtecontent-encodingest absent), des feuilles de style, des polices et des images chargées avantload; 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. - 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). - 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-vitalsn'est pas installée, y compris lorsque l'analytique est activée (Umami ne mesure pas les Web Vitals). - 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 VitestRègles d'arborescence :
src/appne contient que des fichiers de routage (etfonts.ts,icon.svg,apple-icon.png). Aucun composant réutilisable n'y vit ; les composants spécifiques à une section vivent danssrc/components/sections/<section>/.- 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. 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 danssrc/app/et non danspublic/.- Aucun dossier de premier niveau supplémentaire (
utils/,helpers/,types/,services/,assets-src/) n'est créé. src/middleware.ts/src/proxy.tsn'existent pas en MVP : les en-têtes de sécurité passent parheaders()denext.config.ts, qui appelle le constructeur pursrc/lib/security/headers.ts(§10).- 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'étapetestdu Dockerfile (le contexte de build exclut une partie des fichiers qu'ils vérifient, §14.3) mais dans le jobqualityde la CI (§14.15). - 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 danssrc/(règle ESLintno-restricted-importsmotif../*). - 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 #
- Server par défaut. Un fichier sans
'use client'est un Server Component. Les sections*-section.tsxsont des Server Components qui lisentgetContent('fr')(etgetBookingAction()pour les liens WhatsApp) et passent des props sérialisables et minimales aux îlots. '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).- Î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 passantchildren, plutôt que de passer toute la section en client. - Props. Types explicites (
interface XProps), pas deReact.FC, pas deany, pas de props booléennes opaques à plus de trois (utiliser une union de variantes).childrentypéReact.ReactNode. - Pas d'effet de bord à l'import. Aucun
window/documentau niveau module ; accès navigateur uniquement dansuseEffect/gestionnaires, avec gardetypeof window. - É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-motionpour tout mouvement (§8). - 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) ; aucunoutline: nonesans remplacement visible ; aucuntabindexpositif. - Pas de CTA qui fabrique une URL. Les liens WhatsApp passent exclusivement par
getBookingAction()(§9.9), appelée côté serveur, et le composantui/booking-link.tsx; les îlots reçoivent l'hrefen prop et ne construisent aucune URL. - Pas d'import de contenu dans un îlot. Un îlot ne reçoit jamais l'objet
getContent()entier et n'importe nisrc/content/fr/*nilib/content/*nilib/booking/index.ts(règle ESLint, §12.8.1). - Images. Toute image raster passe par
next/imageavecsizesexplicites,alt(vide uniquement pour une image décorative) etquality60 ou 75 ;width/heightoufilldans un conteneur de ratio fixe garantissent zéro layout shift. Seulhero-farreçoitpriority. L'élément<img>brut est interdit (@next/next/no-img-element) ; les SVG sont rendus avecunoptimized(§12.10.3). - 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 #
- Tailwind en utilitaires + tokens. Les couleurs, rayons, ombres, espacements de marque et polices viennent des tokens
@themedesrc/styles/globals.css(§5). Utiliserbg-forest-900,text-ivory-50,rounded-card… jamais de valeur brute équivalente. - 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/.tsxhorssrc/lib/design/tokens.ts(qui exporteTHEME_COLORet les couleurs de la page d'erreur racine). Appliqué par ESLint (§12.8.1). - 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)). cn():src/lib/cn.tsexportecn(...inputs: ClassValue[]) => twMerge(clsx(inputs))(paquetsclsxettailwind-merge). Toute composition conditionnelle de classes passe parcn(); pas de concaténation de chaînes ni de template literal pour des classes.- Variantes de composants : objets
Record<Variant, string>typés dans le fichier du composant, pas de bibliothèque de variantes supplémentaire. - CSS hors Tailwind limité à
globals.css(et àmotion.cssqu'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. - Ordre des classes imposé par
prettier-plugin-tailwindcss(§12.8.3). - Mobile-first. Les classes de base visent 320 px ;
md:,lg:… ajoutent. Aucunmax-*:sauf justification en commentaire. - Aucune animation en CSS libre (
transition: all,@keyframesdécoratifs hors demotion.css) : le mouvement est porté par Motion (§8) et par les feuilles desrc/styles/motion.css(séquence du hero, ambiances, micro-interactions) ; seules les transitions de couleur/opacité des états de survol/focus utilisenttransition-colors/transition-opacityavec les durées de §5. Toute transformation de survol ou d'appui passe par.motion-press,.motion-liftou.motion-arrow(neutralisées en mouvement réduit) ; les classeshover: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,noFallthroughCasesInSwitchsont obligatoires et ne sont désactivés nulle part (ni par fichier, ni par// @ts-ignore).// @ts-expect-errorest toléré uniquement danstests/avec une raison écrite.- Les chaînes de type « union de littéraux » remplacent les
enum. Lesswitchsur une union se terminent par undefaultqui affecteconst _exhaustive: never = value. import type/export typesystématiques pour les types (verbatimModuleSyntax).- Les données de
src/content/fr/utilisentsatisfiesavec les types delib/content/types.tspour conserver les littéraux et vérifier la forme. anyest interdit (ESLint@typescript-eslint/no-explicit-any) ; l'entrée non fiable est typéeunknownpuis 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 danssrc/lib/env.ts,src/instrumentation.ts,next.config.ts,scripts/**,tests/**et les fichiers*.config.*des outils ; s'y ajoute la lecture ciblée deAPP_ENVparsrc/lib/content/guards.ts(§9.3.4). Tout autre fichier passe parsrc/lib/env.ts(principe P5). - Littéraux hexadécimaux : autorisés uniquement dans
src/styles/globals.css(hors périmètre ESLint) etsrc/lib/design/tokens.ts.viewport.themeColoret les couleurs inline deglobal-error.tsximportent leurs constantes depuistokens.ts; aucun autre fichier desrc/ne contient de littéral de couleur. - Contenu et composants : les composants serveur (
*-section.tsx,ui/booking-link.tsx) lisentgetContent('fr')etgetBookingAction()et passent des props sérialisables aux îlots ; seuls les îlots (listeclientIslandFiles, identique aux fichiers marqués (C) de §12.2) et les hooks ne peuvent importer ni le contenu nilib/booking/index.ts. react/no-dangerest àerror; la seule exception est le<script type="application/ld+json">rendu par<JsonLd>danssrc/lib/seo/json-ld.tsx, annoté// eslint-disable-next-line react/no-danger -- JSON-LD serialised by JSON.stringifyet jamais alimenté par une saisie visiteur. Aucun autre fichier n'emploiedangerouslySetInnerHTML.@next/next/no-img-elementinterdit l'élément<img>brut : toute image passe parnext/image(§12.5, règle 10).console.*est interdit danssrc/: tout log passe parlib/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 écouteurscroll,wheel,touchmovemanuel). - Un test unitaire
tests/unit/tooling/lint-rules.test.tscharge l'APIESLintet vérifie, vialintText, que : une classebg-[#fff], un littéral'#13301F'dans un composant, unprocess.envdans un composant, un import de@/lib/emaildans un fichiersrc/components/**, un import de@/lib/contentdanssrc/components/sections/method/method-compass.tsx, unhover:-translate-y-1, un<img>sansaltet untest.onlyproduisent chacun au moins une erreur, et que l'import de@/lib/contentdanssrc/components/sections/method/method-section.tsxn'en produit aucune.
12.8.2 Accessibilité statique et dynamique #
- Statique : règles
jsx-a11y/*ci-dessus (échec depnpm lint). - Dynamique :
@axe-core/playwrightdans les parcours E2E (§15.7) — aucune violation de niveauserious/criticalsur/,/mentions-legales,/politique-de-confidentialiteaux largeurs et dans les états définis en §15.7 ; les violationsmoderate/minorsont consignées dansdocs/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.
- 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. - 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:assetsrefuse un fichier qui en contient (ASSET_METADATA). - 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.
- 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.
- Précharge. Seul
hero-farest 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 :
- Charger
getContent('fr')(échec Zod = erreur immédiate, le message de Zod est reformulé en français avec le chemin du champ). - 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 fixeog/og-default.jpg. - Pour chaque référence : vérifier l'existence du fichier sous
public/; pour un raster, lire ses métadonnées avecsharp(...).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). - Contrôles 12.11.2 à 12.11.5.
- Afficher un rapport groupé (erreurs puis avertissements) et sortir avec le code
0(aucune erreur) ou1.
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 surmainavant 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-buildetsecurityverts (etvisualsurmain) ; 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.PATCHsurmain; le tag déclenche aussi le jobrelease-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.
- 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).
- Contenu : aucun texte visiteur, prix, numéro ou chiffre en dur hors
src/content/fr/; aucun contenu inventé (témoignage, chiffre, mention légale). - Serveur / client : nouveau
'use client'justifié ; aucun module serveur importé côté client ; props sérialisables et minimales. - Types : aucun
any,@ts-ignore, assertion non nécessaire ; entrées externes validées par Zod. - Style : tokens uniquement, aucune couleur arbitraire,
cn()utilisé, mobile-first, aucun dépassement horizontal à 320 px. - 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. - Mouvement : seuls
transform/opacity/filter/clip-pathanimés ;prefers-reduced-motionrespecté ; transformations de survol uniquement via.motion-*; effets lourds désactivés sous 1024 px et en pointeur grossier. - Performance : pas de dépendance ajoutée sans mesure de l'impact sur le JS de première charge (
pnpm check:bundle) ; images vianext/imageavecsizesetalt; polices vianext/font. - 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. - 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.
- Docker / CI :
docker buildetdocker compose configOK siDockerfile/compose*.yaml/Caddyfiletouchés ; aucune variable d'environnement ajoutée sans mise à jour de.env.exampleet de §13. - 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 #
- 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 servicewebde Compose ne les redéclare jamais : sa listeenvironment:est explicite et ne contient que des variables d'exécution (§13.6), si bien qu'une valeur de build modifiée dans.envne peut pas diverger de celle qui est figée dans l'image. - Aucun secret en build.
BREVO_API_KEYetLOG_SALTne sont niARGniENVdu Dockerfile (§10.4). - 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 danssrc/(0 occurrence). - 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.*) lisentprocess.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.tslitAPP_ENVlui-même (énumération locale) et n'importe passrc/lib/env.ts, marquéserver-only. - É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).
- 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 valeurstrueetfalsesont admises (1etyessont rejetées). - 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 nilocalhost, ni adresse IP (IPv4 ou IPv6), niexample.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(URLhttpsvalide, sans identifiants) etUMAMI_WEBSITE_ID(UUID) sont obligatoires (garde G-10) ; sinone, ces deux variables sont ignorées. - E-mail :
EMAIL_DRY_RUNvaut par défauttrueendevelopment(tests compris) et enstaging, etfalseenproduction, où la valeurtrueest 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_ADDRESSetLOG_SALTsont requis, quel que soitAPP_ENV. En production,EMAIL_DRY_RUN=trueest une erreur de configuration ;EMAIL_FROM_ADDRESSne 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=localproduit 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=300013.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(plusNODE_ENV, posée par l'image) ;grep -rn "process.env" src/ne trouve des occurrences que danslib/env.tsetinstrumentation.ts; en dehors desrc/, seulsnext.config.ts,scripts/**,tests/**et les fichiers*.config.*lisentprocess.env. - AC-13-02 —
grep -rn "NEXT_PUBLIC_" src/ne renvoie aucune occurrence. - AC-13-03 — Environnement minimal de développement (
parseEnv({})) : succès, avecEMAIL_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_RUNabsent ou vide :trueendevelopment(tests compris) et enstaging,falseenproduction;EMAIL_DRY_RUN=trueavecAPP_ENV=productionéchoue au démarrage avec un message nommant la variable. - AC-13-05 —
APP_ENV=productionsansBREVO_API_KEY, sansLOG_SALTou sansEMAIL_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 :
trueetfalseacceptés ;yeset1rejeté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/cheminrejetés ; barre finale retirée hors production, rejetée en production ;http://localhost:3000refusé en production ;https://example.comrefusé en production ; adresse IP refusée en production. - AC-13-08 —
ANALYTICS_PROVIDER=ga4échoue ;umamisansUMAMI_SCRIPT_URLhttps ou sansUMAMI_WEBSITE_IDUUID échoue ;noneignore ces deux variables. - AC-13-09 —
EMAIL_FROM_ADDRESSen@gmail.com(et chacun des domaines de la liste du §13.4) échoue en production ; accepté en développement. - AC-13-10 —
PORTnon numérique ou hors 1–65535,CONTACT_TO_EMAILouEMAIL_FROM_ADDRESSinvalides,APP_ENVhors énumération : échec. - AC-13-11 — Pendant
next buildsans 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_invalidest 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 jamaishealthy. - AC-13-13 —
.env.examplecontient chaque variable du §13.2 (active ou commentée) ainsi queCOMPOSE_PROFILES,COMPOSE_FILE,SITE_DOMAIN,ACME_EMAILetWEB_PORTdu §13.3, aucune valeur pourBREVO_API_KEYetLOG_SALT, aucun guillemet, et le domaineexample.com; un test confronte la liste de ses clés à ces tableaux. - AC-13-14 — L'image finale ne contient ni
BREVO_API_KEYniLOG_SALTdansdocker 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_URLsans 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
webdecompose.yamlne déclare pasenv_file;docker compose confign'y fait figurer que la listeenvironment:de §13.6 (aucune variable de build) ; avecSITE_URLmodifié dans.envpuisdocker compose up -dsans reconstruction, la variableSITE_URLdu conteneur reste celle de l'image. - AC-13-17 — Toute variable lue par Compose, le Makefile, la CI, le Dockerfile ou un script (
grepdes motifs${…},$(…),process.env.…horssrc/) figure dans §13.2 ou §13.3. - AC-13-18 —
src/lib/content/guards.tsne lit pasprocess.envet n'importe passrc/lib/env.ts(il reçoitAPP_ENVen argument) ;scripts/check-assets.tsn'importe passrc/lib/env.ts; l'imagerunnerne 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 :
HOSTNAME: Docker positionneHOSTNAMEau nom du conteneur ; l'image le fixe à0.0.0.0pour que le serveur écoute sur toutes les interfaces.compose.yamlle fixe aussi explicitement (§14.4) ; ne jamais le définir dans.env.- Variables de build figées :
SITE_URL,APP_ENV,ANALYTICS_PROVIDER,UMAMI_*etAPP_VERSIONsont aussi desENVdu runner afin que la vérification d'origine et le health check lisent la même valeur que la pagination statique.compose.yamlne 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. - Aucun secret dans l'image :
BREVO_API_KEYetLOG_SALTne sont niARGniENVdu Dockerfile ;.envet tout fichier.env*(hors.env.example) sont exclus du contexte (§14.3). - Dossiers inscriptibles : l'application n'écrit que dans
/tmpet/app/.next/cache, montés entmpfspar Compose (§14.4). - Garde-fous de production au build uniquement : si
APP_ENV=productionet qu'un visueldev-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-buildouscripts/check-assets.ts) et jamais au démarrage du conteneur : l'imagerunnerne contient ni ces fichiers niscripts/, et unPOST /api/contactvalide y répond200. - 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). sharpetnext/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 desharpest résolue par l'installation surnode:24-alpineet incluse dans la sortiestandalone. L'encodage AVIF de maîtres de 3840 px est gourmand : le conteneurwebdispose de 768 Mo (§14.4),images.localPatternsest limité à/images/**, etscripts/warm-cache.shpréchauffe le cache après chaque déploiement (§14.7,make warm).- Cibles de test :
builder-fixturesetrunner-fixturesn'existent que pour le serviceweb-e2e-fixturesdu profil Composee2e(§14.4) ;make build,make deployet les jobs de livraison de la CI ne les construisent jamais, et le build échoue siAPP_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_Storedocs/ 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 lancerpnpm devdirectement sur l'hôte. Les volumes nommésnode_modules,.nextet 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=truedans le shell avantmake dev. - Les commandes
make lintetmake typecheckexécutent le même service avec une commande différente (§14.7). make devne lit pas.env: la configuration de développement est entièrement dans ce fichier.- Ouvrir le site sur
http://localhost:3000, pas sur127.0.0.1ni sur l'adresse IP de la machine : le contrôle d'origine de l'API compare l'en-têteOriginà la valeurSITE_URLdu 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 :
- Domaine : tout vient de
SITE_DOMAIN(apex sans schéma) ; l'apex est le domaine de référence,wwwredirige en301en conservant chemin et requête ({uri}). - Adresse IP cliente : aucun
trusted_proxiesn'est configuré, donc Caddy ignore toutX-Forwarded-Forfourni par le visiteur et transmet l'adresse réelle de la connexion. L'application, avecTRUST_PROXY=true, lit la dernière valeur deX-Forwarded-For(§9.6) : celle que le proxy de confiance a écrite ou ajoutée, jamais une valeur fabriquée par le visiteur. - Pas de journal d'accès : aucune directive
logn'est déclarée ; Caddy ne journalise que ses erreurs de fonctionnement (adresses IP des visiteurs non conservées). - 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 duCaddyfiles'applique pardocker compose restart caddy. - Aucun en-tête de réponse ajouté : ni
header, niheader_down, niCache-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 estheader_up Accept-Encoding identity, qui concerne la requête vers l'application. - Compression : l'application n'est jamais compressée deux fois (
Accept-Encoding: identityen amont) ; sous Coolify ou un proxy existant, la compression native de Next.js reste active. - Émission du certificat : Caddy obtient aussi le certificat de
www; l'enregistrement DNS dewwwdoit donc exister avant le premier démarrage (§14.10). - 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; }; \
fiRè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=proxyactive le servicecaddysur un VPS ; il reste vide sous Coolify ou avec un proxy existant.e2en'y est jamais ajouté (le Makefile active ce profil explicitement).COMPOSE_FILE=compose.yaml:compose.hostport.yamlajoute la publication locale deweb(proxy existant) ;WEB_PORTfixe alors son port local.SITE_DOMAIN(apex sans schéma, requis avec le profilproxy) etACME_EMAIL(requis avec le profilproxy) nomment le site Caddy et servent à l'URL de vérification du Makefile ;SITE_URLdoit valoirhttps://$SITE_DOMAIN.ACME_CA=https://acme-staging-v02.api.letsencrypt.org/directorypermet un essai à blanc (certificat non reconnu par les navigateurs) ; sans valeur, l'autorité de production est utilisée.APP_VERSIONest 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_UIDetHOST_GID(id -u,id -g) donnent aux rapports Playwright le bon propriétaire ;PLAYWRIGHT_BROWSERS="chromium firefox webkit"avecPW_ALL_BROWSERS=1active la matrice complète (§12.8.4).LHCI_BASE_URLredirigemake lighthousevers un autre site quehttp://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 :
- 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.
- 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.
- SPF : une seule entrée
TXTv=spf1 …par domaine. S'il en existe déjà une, y insérer le mécanismeinclude:fourni par Brevo (exemple :v=spf1 include:spf.brevo.com ~all) au lieu d'en créer une seconde. - DMARC :
TXTsur_dmarcavecv=DMARC1; p=none; rua=mailto:<adresse de rapport>pendant les 2 à 4 premières semaines d'observation, puis passage àp=quarantinesi les rapports ne montrent que des envois légitimes. L'adresse de rapport est fournie par Malka. - Cliquer sur « Authentifier » dans Brevo ; attendre le statut « authentifié » (propagation jusqu'à 24 h, souvent < 1 h).
- 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.tsentièrement renseigné par Malka (§9.3), y comprisprivacy.retention. - Visuels réels en place dans
public/images/etpublic/og/, aucune référencedev-fallback; registredocs/assets-licences.jsoncomplet (§12.11.4). -
content-validation.jsoncomplé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 :
- Préparer le serveur (connecté en
rootune seule fois) : créer l'utilisateurdeploy, installer sa clé SSH, désactiver mot de passe/root SSH, activer le pare-feu (22, 80, 443/tcp, 443/udp), installerunattended-upgrades, Docker Engine + Compose,make,git,curl,jq. Attendu :docker run --rm hello-worldréussit pourdeploy. - DNS : créer
A/AAAAetCNAME www(§14.10.1). Attendu :dig +short <domaine>= IP du VPS. - Cloner :
git clone <url-du-dépôt> ~/malka-site && cd ~/malka-site && git checkout main. - 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(ouumamiavec ses deux variables, §13) ;BREVO_API_KEY,EMAIL_FROM_ADDRESS;LOG_SALT: générer la valeur dans le terminal avecopenssl rand -hex 32, puis coller le résultat aprèsLOG_SALT=(Compose n'évalue aucune substitution$(…)dans.env) ;EMAIL_DRY_RUN=false(ou ne pas définir la variable) :trueest interdit en production et empêche le démarrage.
- Contrôler :
make check-env→ aucune erreur.docker compose config --quiet→ aucune erreur. (Optionnel :ACME_CAstaging pour un premier essai de certificat sans consommer les limites.) - Construire :
make build. Attendu : fin sans erreur,docker image ls malka-siteaffiche 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 passantAPP_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. - Démarrer :
make up. Attendu :webetcaddy« healthy ». Le certificat est obtenu en 10 à 60 s :docker compose logs --no-log-prefix caddy | grep -i "certificate obtained". - Vérifier le site (depuis un poste extérieur) :
curl -sI https://<apex>/→HTTP/2 200;curl -sI http://<apex>/→301/308vershttps;curl -sI https://www.<apex>/→301vershttps://<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.txtréférencehttps://<apex>/sitemap.xml;curl -sI https://<apex>/ | grep -i x-robots-tagne renvoie rien (site indexable) ;- ouvrir
https://<apex>/et parcourir hero, méthode, offres, orientation ; ne pas soumettre le formulaire avant l'étape 9.
- Vérifier l'envoi réel : soumettre une demande réelle depuis le formulaire. La notification arrive à
astheoris.pro@gmail.comavecReply-To= e-mail saisi, l'accusé arrive au visiteur, SPF/DKIM/DMARC passent (§14.10.2). Vérifier aussi le dossier spam. Puismake verify. - Test de redémarrage :
sudo reboot; après 2 minutes,make psmontrewebetcaddy« healthy » sans intervention. - Supervision : créer la vérification externe de
/api/health(§14.17). - Audit final : lancer Lighthouse mobile sur l'URL publique, sans la configuration de la CI (qui désactive l'audit
is-crawlablepour les builds non indexables) : Performance ≥ 90, Accessibilité 100, Bonnes pratiques ≥ 95, SEO 100 ; conserver le rapport dansdocs/perf-report.md. - Consigner : créer le tag
vX.Y.Zsur 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 :
- Vérifier que le commit à déployer est sur
mainavec la CI verte et, pour une version, le jobrelease-gatevert (§14.15). make tags: noter le tag actuellement en service (point de retour).- Si
.envdoit changer (variable de build ou secret), le modifier d'abord (chmod 600conservé). make deploy: contrôle de l'arbre Git propre et de la branchemain→git pull --ff-only→check-env→docker compose build --pull web→up -d --wait→verify→warm(préchauffage du cache d'images) →prune.- Contrôler le résultat :
make verifyaffiche « healthy » et l'URL publique répond ;curl -s https://<apex>/api/health | jq -r .data.version= SHA attendu. - 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 #
make tags: choisir l'étiquette SHA précédente (les 3 dernières images de production sont conservées par défaut).make rollback TAG=<sha>: redémarrewebsurmalka-site:<sha>sans reconstruire (seules les étiquettes SHA des images de production sont acceptées), attend la santé, exécutemake verify.- Contrôler
curl -s https://<apex>/api/health | jq -r .data.version=<sha>. - Ouvrir une PR de correction ou de
revertsurmain. Ne pas relancermake deployavant correction :git pullrepartirait sur le commit défectueux. - Si l'image visée n'existe plus localement :
git checkout <sha> && make build && make up, puisgit 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 :
- 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.yamln'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. - Domaines : sur le service
web, saisirhttps://<apex>:3000(le port interne 3000 est celui à router). Définir la redirectionwww→ apex dans Coolify (domainewwwen redirection) ; ne pas déclarer de domaine pourcaddy. - 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_VERSIONpeut rester vide : le build utiliseSOURCE_COMMITfourni 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éfinirEMAIL_DRY_RUNen production (ou la mettre àfalse). - Ne pas définir
COMPOSE_PROFILES,SITE_DOMAIN,ACME_*,HOSTNAME,PORT.
- Disponibles au build :
- Santé : le
healthcheckdecompose.yamlest utilisé par Coolify ; si l'interface demande un chemin, saisir/api/health, méthode GET, port 3000, code attendu 200, intervalle 30 s. - 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é. - Les profils
proxyete2en'étant pas activés, Coolify ne démarre queweb. 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), doncTRUST_PROXY=truereste 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 bienRATE_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églerTRUST_PROXY=false(seau commun, restrictif mais sûr).- Le
.envn'existe pas dans Coolify ; l'application lit les variables listées dansenvironment:(§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=runnerRègles du workflow :
- Aucun secret n'est requis par la CI. Les valeurs factices des jobs
e2e,lighthouseetdocker-buildsont publiques par construction et protégées parEMAIL_DRY_RUN=true. - Les builds de CI utilisent
APP_ENV=stagingpour 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 jobrelease-gate, bloquant pour toute version taguée. La ciblerunner-fixturesn'est construite que par le jobe2eetvisual(via le profil Composee2e), jamais pardocker-buildnirelease-gate. - Un build non production est non indexable (voir §11) ; c'est pourquoi l'audit
is-crawlableest 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) etdocker-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
unhealthyn'est pas redémarré par Docker seul :restart: unless-stoppedne 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 estdocker compose restart web(puismake 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 #
- 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
fetchsubstitué ;wa.meest intercepté côté navigateur. - 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 (bloc203.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 lienswa.meettel:. - Les contrats priment sur l'implémentation. Les tests d'intégration interrogent la route
POST /api/contactselon le contrat de §9.4 (codes, enveloppe d'erreur, en-têtes), pas selon la structure interne des modules. - 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.
- 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).
- Les fixtures ne quittent jamais
tests/(garde-fous en §15.10). - 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ôleL'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 : <script>alert(1)</script> ; 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 (", >, <) |
| U-EML-03 | message = 'a & b & c' |
a & b &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 :
fetchest remplacé parvi.stubGlobal('fetch', fetchMock); tout appel à une URL autre quehttps://api.brevo.com/v3/smtp/emailfait é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-Forunique (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.ackou le nombre total d'appels à Brevo appelle d'abordawait 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) positionnentEMAIL_DRY_RUN=false,BREVO_API_KEY,LOG_SALTetEMAIL_FROM_ADDRESSde 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émentaria-hidden="true"sont exclus : ils sont décoratifs et superposés par conception (le mot se trouve derrière le portrait). hero-portraitdé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 cadrehero-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 surlocalhost:3000depuis l'hôte, voir §14.4).make lighthouseen dérive les URL à partir de la variable d'outillageLHCI_BASE_URLlorsqu'elle est définie (option--collect.url). - Les mesures s'effectuent sur le build de production du conteneur construit avec
APP_ENV=staging(ciblerunner, profile2eoumake lighthouse), jamais sur le serveur de développement. - Les builds de test sont volontairement non indexables (
noindex,robots.txtenDisallow: /,X-Robots-Tag, §11.1.7). L'auditis-crawlableest donc retiré du calcul parskipAudits(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 (curlderobots.txt, absence deX-Robots-Taget denoindex). - 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 » :
- 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) ; - sous 1024 px le candidat LCP est le
<h1>(texte, jamais d'opacité nulle) ; LCP ≤ 2,5 s en laboratoire ; - 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 :
web-e2e: ciblerunner, contenu livré tel quel ;web-e2e-fixtures: ciblerunner-fixtures. Dans l'étape de construction de cette image seulement (jamais dans l'arbre de travail), elle remplacesrc/content/fr/testimonials.tspartests/fixtures/content/testimonials.two.tsetsrc/content/fr/legal.tspartests/fixtures/legal/legal.complete.ts, puis lancepnpm build;- chaque service est construit avec
APP_ENV=stagingetSITE_URLégal à son URL interne (http://web-e2e:3000,http://web-e2e-fixtures:3000) ; la variantefixturesactive en plusANALYTICS_PROVIDER=umamiavec 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çoiventEMAIL_DRY_RUN=true,TRUST_PROXY=true,SEND_ACKNOWLEDGEMENT=trueet unLOG_SALTde test ; - le service
e2e(cible de lanceur Playwright) litPLAYWRIGHT_BASE_URLetPLAYWRIGHT_FIXTURES_URLet n'écrit jamais danssrc/; la ciblerunner-fixturesn'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/) fixentretries: 0et 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 #
- Le code respecte
pnpm lint,pnpm format:checketpnpm typechecksans avertissement. - Les tests de la tâche (unitaires, composants, intégration, E2E selon le cas) sont écrits avant ou avec le code et passent.
- Les tâches CI 1 à 5 et 7 à 8 de §15.11 sont vertes.
- 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.
- Aucun texte visible n'est écrit en dur dans un composant : tout vient de
src/content/fr/(voir §9.3, §7). - Aucune dépendance n'a été ajoutée sans l'entrée
DECISIONS.mdexigée par §18.6. - 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.
- 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 :
- AC-G-01 à AC-G-30 sont satisfaits, preuves archivées dans
docs/go-live-report.md. - Tous les jalons M0 à M8 sont clos (§16).
- 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.
- La checklist manuelle MAN-01 à MAN-14 est consignée sans anomalie bloquante.
- 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.
- 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. - Le dépôt est remis avec
README.mdcomplet,DECISIONS.md,.env.examplecomplet (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] ───► M816.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.
- Initialiser le dépôt Git,
.gitignore,.dockerignore,.editorconfig,.nvmrc,README.mdminimal (les 16 rubriques de §18.9.1 y figurent en titres) etDECISIONS.md(en-tête de §18.7). - 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. - Installer et configurer ESLint (flat config unique de §12.8.1, avec les règles d'import des fixtures, de
encodeURIet du composantmotioncomplet), Prettier, Vitest (un fichier, trois projets, aliasserver-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 scriptbuildvautnext buildjusqu'à M1, oùpnpm check:assetslui est préfixé (le contrôle charge le contenu typé, qui n'existe pas encore). - Créer l'arborescence de §12 avec des modules vides typés.
- 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. - Écrire
Dockerfilemulti-étapes (deps,builder,test,runner),compose.yaml(serviceweb,healthcheck, journaux, système de fichiers en lecture seule),compose.dev.yaml,Makefile(cibles de §14),.env.example(voir §13). - Créer le
.envlocal de vérification (non versionné, jamais déployé), copié de.env.examplepuis ajusté :APP_ENV=staging,SITE_URL=http://localhost:3000,EMAIL_DRY_RUN=true,BREVO_API_KEY=dev-placeholder-0123456789,LOG_SALTd'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 valentAPP_ENV=productionavec unSITE_URLvide et la construction échoue volontairement (§14.2) ; tous les critères de sortie de M0 à M7 qui lancent Docker oupnpm buildutilisent ce fichier. - Mettre en place la CI (jobs
quality,unit-integrationetdocker-buildde §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 down16.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.
- Déclarer les jetons de couleur, rayons, espacements et typographie (§5) dans
src/styles/globals.css(@theme) ; charger Fraunces (romain) et Manrope vianext/font/google(auto-hébergées au build) avec les variables--font-displayet--font-sans, puis une seconde instance de Fraunces en italique (preload: false, variable--font-display-italic) avecfont-synthesis: none(§5.5). - Primitives :
Container,Section,Heading,Button(primaire, secondaire, tertiaire),VisuallyHidden,Icon(imports nominatifslucide-react),SkipLink, styles d'anneau de focus (§5). - Contenu : écrire
src/lib/content/schemas.ts(§9.3.3, propriétaire des clés), les 13 fichiers desrc/content/fr/avec les valeurs livrées (offres, prix, chiffres, coordonnées, textes de §7), clé pour clé conformes à ces schémas, et le chargeurgetContent('fr'). - Utilitaires :
src/lib/cn.ts,src/lib/design/tokens.ts, formateur de prixfr-FR, constructeur de lien WhatsApp etgetBookingAction(serveur uniquement), helper de typographie françaisesrc/lib/typography/fr.ts,track()(no-op), gardes de production (légal, actifs, marqueurs, registre de validationcontent-validation.json). - 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) ; registredocs/assets-licences.json(structure vide valide) etscripts/check-assets.ts(dimensions minimales viasharp, canal alpha des calques, entrée de licence, absence de repli en production) ; le scriptbuilddevientpnpm check:assets && next build. - 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-*.
- Harnais E2E (il doit exister avant tout critère de sortie qui lance
make test-e2e) : cible Dockerfilerunner-fixtureset services Composeweb-e2e,web-e2e-fixtures,e2e(§15.6.2, §14.4),playwright.config.tssanswebServer,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/healthré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-fixtures16.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.
app/layout.tsx(<html lang="fr">, polices,MotionConfigprévu à M3), lien d'évitement,<main>, repères ARIA, identifiants d'ancres etscroll-margin-top(§4.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 parIntersectionObserver, menu mobile plein écran accessible (role="dialog", piège de focus, verrouillage du défilement parhtml[data-scroll-locked]). - 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/imageavec dimensions explicites,priorityréservé àhero-far. - Squelettes des sections C à J (titres et ancres) pour valider la navigation et l'état actif.
- Hooks de test de §15.2.5 pour la navigation et le hero.
- 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.
MotionConfig reducedMotion="user"etLazyMotion(domAnimation) danslayout/providers.tsx; jetons de durée, de courbe et de cascade et variantes partagées danssrc/lib/motion/; composantsReveal,RevealLines,StaggerGroupdesrc/components/motion/.- Hooks de
src/hooks/(use-motion-profile,use-in-view-once,use-reveal-phase,use-ambient-state) et composantsParallaxLayeretAmbientDrift(pause hors écran et onglet masqué). Les composants rendent toujours des élémentsm.*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. - 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-mid64 px, portrait 24 px,hero-near0, 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. - Version mobile simplifiée (sous 1024 px : paysage aplati
hero-mobiledans la bande encadrée, pas de flou, pas de parallaxe). - Règles ESLint de §12.8.1, script de budget
scripts/check-bundle-size.ts(pnpm check:bundle) ; premier rapport de performance dansdocs/perf-report.md. - 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.
- Philosophie (§6.3) : typographie éditoriale, révélation ligne à ligne.
- 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 videsid="methode-<id>"), événementmethod_step_open. - Accompagnements (§6.5) : une offre mise en avant en panneau sombre puis des lignes éditoriales (
OfferItem, variantespaneletrow, sans grille de cartes), libellé « Tarif », prix formatés, détails en<details>natif, mention TVA depuislegal.ts, CTA « Écrire sur WhatsApp » (lien reçu en propriété depuisgetBookingAction, appelé côté serveur) et lien « Écrire via le formulaire » (href="/?offre=<id>#contact",requestContactPrefill({ offerId })). - 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 })). - À propos (§6.7) : structure et portrait (repli) ; texte conforme aux propositions de §7 marquées « à valider par Malka ».
- 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.
- 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 lighthouse16.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.
src/lib/contact/schema.ts(schéma Zod partagé) ;POST /api/contactselon §9.4 : ordre normatif des contrôles (méthode, type de contenu, origine etSec-Fetch-Site, taille, JSON, limitation de débit, spam, validation, notification, réponse 200, puis accusé de réception parafter()), enveloppe d'erreur,requestId, méthodes non permises.EmailProvideret adaptateur Brevo (fetchnatif, délai de 8 s, une reprise après 500 ms), gabarits échappés (§9.5), mode à blanc (EMAIL_DRY_RUN=truepar défaut en développement, test et staging ; interdit en production).- 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 deTRUST_PROXY(IP cliente = dernière entrée deX-Forwarded-For, §9.6). - Journalisation sans donnée personnelle (§9.7).
- Formulaire (§6.9) : états de chargement (
aria-busy, champs en lecture seule, boutonaria-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énementcontact:prefill(le champ message n'est jamais prérempli), honeypot,startedAtavec calibrage d'horloge. - 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.
- 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). - Pages
/mentions-legaleset/politique-de-confidentialitealimentées parlegal.ts(dont la durée de conservationprivacy.retentionfournie par Malka) ; bandeau de développement ; sections de confidentialité de §6.11 dont la sectionaudienceconditionnelle. - Pages système :
not-found,error,global-error(§6.11). - 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. - En-têtes de sécurité et CSP (§10) construits par le module pur
src/lib/security/headers.tsutilisé parnext.config.ts(HSTS :includeSubDomainsen production seulement, 300 s en staging ;X-Robots-Taghors production) ; module d'analyse optionnel (Umami) désactivé par défaut. - 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.
- 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 registredocs/assets-licences.json; remplacer les référencesdev-fallback; exécuterpnpm check:assets. - 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. - 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.
- Exécuter la checklist manuelle MAN-01 à MAN-14 ; corriger ; rédiger
docs/a11y-report.mdetdocs/perf-report.md. - Revue de sécurité : en-têtes, CSP, journaux sans donnée personnelle, absence de secret dans le bundle (GT-04),
pnpm audit. - README complet (les 16 rubriques de §18.9.1) ;
.env.exampleconforme à §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=highTous 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.
- 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).
- 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_SALTavecopenssl rand -hex 32dans un terminal et coller le résultat dans.env(jamais de substitution$(…)dans.env). - Créer
.envde production (voir §13) avecEMAIL_DRY_RUN=false(le mode à blanc est interdit en production : le démarrage échoue) ; construire avecAPP_ENV=productionet les arguments de build (§14) ;APP_VERSION= empreinte courte du commit. - 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 profilproxy, en activant l'option « Connect to Predefined Network » de la ressource (§14). - Déployer (
make deploy), vérifier la santé, rejouer les tests de fumée sur l'URL publique (E2E@smokeavecPLAYWRIGHT_BASE_URLpointant vers la production, hors envoi réel). Vérifier l'indexabilité de la production :robots.txtautorise l'exploration et référence le plan du site, aucun en-têteX-Robots-Tagninoindex(§14.11). - 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. - 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). - Mesurer Lighthouse sur l'URL publique (
make lighthouse LHCI_BASE_URL=https://<domaine>) ; remplirdocs/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 deployContrô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 #
- 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.
- 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.
- 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.
- Tout écart est tracé dans
DECISIONS.md(§18.7). - 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 |
| §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 :
- 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. - Les exigences de sécurité, d'accessibilité et de non-collecte de données l'emportent sur les exigences esthétiques.
- 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 :
- Lire la description du jalon et les sections qu'elle cite.
- Créer une branche
feat/mN-<sujet>à partir de la branche principale à jour. - Écrire les tests d'abord ou avec le code pour chaque tâche (identifiants de §15).
- Implémenter en respectant §12 (arborescence, nommage, conventions) ; aucun texte visible en dur dans un composant.
- Vérifier localement avec les commandes ci-dessous.
- Valider les critères de sortie du jalon (bloc de commandes de §16) ; coller leur sortie dans
docs/progress.md. - Consigner les décisions dans
DECISIONS.mdet mettre à jourdocs/progress.md. - 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 :
- Rédiger, paraphraser ou imaginer un témoignage ; publier un témoignage sans consentement
granteddaté (§9.3) ; ajouter un nom de client autre que ceux prévus ; afficher une statistique autre que 300+ et 50+. - 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.
- 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.
- 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.
- Ajouter une variable
NEXT_PUBLIC_*, ou lire un secret côté client. - 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). - 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.skipsans entréeDECISIONS.md; supprimer un test qui échoue au lieu de corriger le code. - 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.mdet conserver la valeur de la spécification. - Écrire une promesse de résultat, une allégation de santé ou thérapeutique, un diagnostic, une garantie ; modifier les avertissements de §7.
- 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.
- 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.
- Animer des propriétés de mise en page (
width,height,top,left,margin), ou ignorerprefers-reduced-motion. - Commiter un secret, une clé, un fichier
.env; écrire un secret dans un journal, une capture ou un rapport. - Publier ou déployer en production sans validation de la porte de §15.11.
- 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 #
- Conflit entre deux sections : appliquer §18.3, consigner dans
DECISIONS.md. - Ambiguïté : choisir l'option la plus conservatrice (sécurité, accessibilité, moindre collecte de données, moindre périmètre), la consigner, continuer.
- 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. - 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.
- Élément de Malka manquant : §18.5.
- 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 #
- Dans l'interface de Brevo, créer une nouvelle clé d'API (ne pas supprimer l'ancienne).
- Mettre à jour
BREVO_API_KEYdans le fichier.envdu serveur (ou dans l'interface de Coolify). - Recréer le conteneur :
docker compose up -d --force-recreate web. - Vérifier :
docker compose psindiquehealthy; envoyer une demande de test depuis le site ; vérifier la réception. - Dans Brevo, révoquer l'ancienne clé.
- Consigner la date de rotation dans
docs/go-live-report.md(jamais la valeur de la clé). - 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 ;
httpredirige vershttps;wwwredirige vers le domaine principal. -
/api/healthrépondok; le conteneur esthealthy; 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.jsonsont 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.mdsont dans le dépôt. - Le
README.mdcontient les 16 rubriques de 18.9.1 ; le guide d'édition de 18.9.2 est présent. -
DECISIONS.mdest à 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.