contact_visuel_ok

This commit is contained in:
Ladebeze66 2026-04-22 20:29:00 +02:00
parent 747193ea3c
commit 1267d60cf2
5 changed files with 337 additions and 83 deletions

View File

@ -3,98 +3,158 @@
import { useState } from "react";
import { sendMessage } from "../utils/sendMessage";
/**
* Formulaire de contact refonte "Digital Atelier" (étape 8).
*
* - Plus de `bg-white shadow-lg rounded-lg` sur le form : il est désormais
* monté dans la carte vellum de `app/contact/page.js`.
* - Champs : `bg-surface-container-low`, radius `rounded-tile`, `focus-visible:ring-2 focus-visible:ring-primary`.
* - CTA jewel : `bg-primary text-on-primary shadow-jewel` avec Material Symbol
* `send` + effet `-translate-y-0.5` au hover, état disabled en `bg-outline-variant/60`.
* - Bandeau status Stitch : succès en `primary-fixed`, erreur en `error-container`,
* chargement en `surface-container`. Chaque état porte une Material Symbol.
*/
export default function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState("");
const [isSuccess, setIsSuccess] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [statusKind, setStatusKind] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const isLoading = statusKind === "loading";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim() || !message.trim()) {
setStatus("Tous les champs sont obligatoires.");
setIsSuccess(false);
setStatus("Tous les champs sont obligatoires.");
setStatusKind("error");
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setStatus("Email invalide.");
setIsSuccess(false);
setStatus("Email invalide.");
setStatusKind("error");
return;
}
setStatus("⏳ Envoi en cours...");
setIsSuccess(null);
setIsLoading(true);
setStatus("Envoi en cours…");
setStatusKind("loading");
try {
await sendMessage(name, email, message);
setStatus("✅ Message envoyé avec succès !");
setIsSuccess(true);
setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
setStatusKind("success");
setName("");
setEmail("");
setMessage("");
} catch (error) {
setStatus("❌ Erreur lors de l'envoi du message.");
setIsSuccess(false);
} finally {
setIsLoading(false);
setStatus("Erreur lors de l'envoi du message.");
setStatusKind("error");
}
};
const statusStyles: Record<typeof statusKind, string> = {
idle: "",
loading:
"bg-surface-container text-on-surface-variant",
success:
"bg-primary-fixed/70 text-on-primary-fixed",
error: "bg-error-container text-on-error-container",
};
const statusIcon: Record<typeof statusKind, string> = {
idle: "",
loading: "hourglass_top",
success: "check_circle",
error: "error",
};
const fieldClass =
"w-full rounded-tile bg-surface-container-low/90 px-4 py-3 font-body text-base text-on-surface placeholder:text-on-surface-variant/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary";
return (
<form
onSubmit={handleSubmit}
className="max-w-lg mx-auto p-6 bg-white shadow-lg rounded-lg animate-fade-in"
>
<h2 className="text-2xl font-headline font-bold mb-4 text-center">📩 Contactez-moi</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-3" noValidate>
<label className="flex flex-col gap-1">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Votre nom
</span>
<input
type="text"
placeholder="Prénom Nom"
value={name}
onChange={(e) => setName(e.target.value)}
className={fieldClass}
required
autoComplete="name"
/>
</label>
<input
type="text"
placeholder="Votre nom"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-3 border border-gray-300 font-headline font-bold rounded mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400"
required
/>
<label className="flex flex-col gap-1">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Votre email
</span>
<input
type="email"
placeholder="adresse@exemple.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={fieldClass}
required
autoComplete="email"
/>
</label>
<input
type="email"
placeholder="Votre email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 border border-gray-300 rounded font-headline font-bold mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400"
required
/>
<textarea
placeholder="Votre message"
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full p-3 border border-gray-300 rounded mb-3 font-headline font-bold focus:outline-none focus:ring-2 focus:ring-blue-400"
required
/>
<label className="flex flex-col gap-1">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Votre message
</span>
<textarea
placeholder="Quelques mots sur votre projet, question ou intention…"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={5}
className={`${fieldClass} min-h-[9rem] resize-y`}
required
/>
</label>
<button
type="submit"
disabled={isLoading}
className={`w-full py-3 rounded transition ${
isLoading ? "bg-gray-400 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-600 text-white font-headline font-bold"
className={`mt-1 inline-flex items-center justify-center gap-2 rounded-tile px-6 py-3 font-headline text-sm font-bold uppercase tracking-widest transition-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
isLoading
? "cursor-not-allowed bg-outline-variant/60 text-on-surface-variant"
: "bg-primary text-on-primary shadow-jewel hover:-translate-y-0.5"
}`}
>
{isLoading ? "⏳ Envoi..." : "Envoyer"}
<span
className="material-symbols-outlined text-base"
aria-hidden="true"
translate="no"
>
{isLoading ? "hourglass_top" : "send"}
</span>
{isLoading ? "Envoi…" : "Envoyer"}
</button>
{status && (
<p
className={`mt-4 text-center ${isSuccess ? "text-green-600" : "text-red-600"}`}
{statusKind !== "idle" && status && (
<div
role="status"
aria-live="polite"
className={`mt-2 flex items-center gap-2 rounded-tile px-4 py-3 font-body text-sm ${statusStyles[statusKind]}`}
>
{status}
</p>
<span
className="material-symbols-outlined text-base"
aria-hidden="true"
translate="no"
>
{statusIcon[statusKind]}
</span>
<span className="min-w-0">{status}</span>
</div>
)}
</form>
);

View File

@ -2,21 +2,41 @@
import { useEffect, useState } from "react";
/**
* Footer éditorial étape 8 "Digital Atelier" (voir docs-site-interne/REFONTE-VISUELLE.md §4).
*
* Avant : `bg-white/50 rounded-lg` + `text-gray-700`. Alignement partiel avec la refonte
* (tracking `visite n°`, font-headline) mais surface et radius hors charte.
*
* Après : carte vellum légère (`bg-surface-container-lowest/70`, `rounded-tile`,
* `backdrop-blur-vellum`) sans ombre ambient le footer ne doit pas flotter autant
* que les cartes de contenu. Trois lignes éditoriales :
* 1. Signature Manrope `text-primary` (identité)
* 2. Pitch Newsreader italic `text-on-surface-variant` (ton éditorial)
* 3. Compteur de visites `text-outline` (méta discrète)
*/
export default function Footer() {
const [visitCount, setVisitCount] = useState(0);
const [year, setYear] = useState(() => new Date().getFullYear());
useEffect(() => {
const visits = localStorage.getItem("visitCount");
const newVisitCount = visits ? parseInt(visits, 10) + 1 : 1;
localStorage.setItem("visitCount", newVisitCount.toString());
setVisitCount(newVisitCount);
setYear(new Date().getFullYear());
}, []);
return (
<footer className="min-h-[80px] w-full min-w-0 rounded-lg bg-white/50 backdrop-blur">
<div className="mx-auto flex max-w-4xl min-w-0 flex-col items-center gap-1 px-4 py-6 font-headline text-sm text-gray-700">
<p>&copy; {new Date().getFullYear()} Gras-Calvet Fernand</p>
<p className="text-[10px] uppercase tracking-[0.3em] text-outline">
<footer className="mx-auto w-full min-w-0 max-w-6xl px-4 pb-6 sm:px-6">
<div className="rounded-tile bg-surface-container-lowest/70 px-6 py-5 text-center backdrop-blur-vellum">
<p className="font-headline text-sm font-bold tracking-tight text-primary">
Fernand Gras-Calvet
</p>
<p className="mt-1 font-body text-xs italic leading-relaxed text-on-surface-variant">
Portfolio Étudiant 42 Perpignan · © {year}
</p>
<p className="mt-3 font-headline text-[10px] uppercase tracking-[0.3em] text-outline">
Visite n° {visitCount}
</p>
</div>

View File

@ -1,34 +1,151 @@
import Link from "next/link";
import ContactForm from "../components/ContactForm";
import { getApiUrl } from "../utils/getApiUrl";
/**
* Page contact étape 8 "Digital Atelier" (voir docs-site-interne/REFONTE-VISUELLE.md §4).
*
* Gabarit aligné sur les autres pages de la refonte :
* - Colonne utile `max-w-3xl` (format lettre, plus intime que les listes `max-w-6xl`).
* - Hero vellum identique aux pages liste (kicker + titre Manrope + pitch Newsreader).
* - Tuiles "canaux" imbriquées (`rounded-tile bg-surface-container-low/80`) avec
* Material Symbols LinkedIn / Facebook / Email. LinkedIn et Facebook utilisent
* `link` et `public` (pas d'icône Material Symbols dédiée aux marques sociales,
* et on veut rester cohérent avec le reste du site en icon-font).
* - Carte vellum principale pour le formulaire (`rounded-sheet`, `shadow-ambient`,
* `backdrop-blur-vellum`) même empreinte visuelle que le hero home.
*/
const canaux = [
{
icon: "link",
label: "LinkedIn",
handle: "Fernand Gras-Calvet",
href: "https://www.linkedin.com/in/fernand-gras-calvet/",
external: true,
},
{
icon: "public",
label: "Facebook",
handle: "Fernand Gras-Calvet",
href: "https://www.facebook.com/fernand.grascalvet",
external: true,
},
{
icon: "alternate_email",
label: "Email",
handle: "grascalvet.fernand@gmail.com",
href: "mailto:grascalvet.fernand@gmail.com",
external: false,
},
];
export default function ContactPage() {
const apiUrl = getApiUrl();
return (
<div className="max-w-3xl mx-auto p-6 flex flex-col justify-top min-h-screen">
<h1 className="bg-white/50 rounded-md text-3xl font-headline font-extrabold tracking-tight text-center mb-6 border-b-4 border-blue-500 pb-2">
📬 Correspondance
</h1>
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6">
{/* Hero éditorial : kicker + titre + pitch. Gabarit identique aux listes. */}
<section
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
aria-labelledby="contact-title"
>
<div className="flex flex-col gap-3 text-center md:text-left">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Contact · Prendre la parole
</span>
<h1
id="contact-title"
className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl"
>
Correspondance
</h1>
<p className="font-body text-base leading-relaxed text-on-surface-variant md:text-lg">
Pour un projet, une question ou une discussion autour de l&apos;IA,
du développement web ou de l&apos;École 42 voici les canaux
ouverts. Le formulaire ci-dessous arrive directement dans mon
back-office.
</p>
</div>
</section>
<p className="bg-white/70 rounded-md font-headline text-lg text-center border-b-4 border-blue-500 pb-2 mb-4">
Vous pouvez me contacter via ce formulaire ou sur mes réseaux sociaux.
</p>
{/* Canaux : 3 tuiles imbriquées, same-tier que les takeaways de la home. */}
<section
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-6"
aria-labelledby="contact-canaux"
>
<div className="mb-4">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Canaux directs
</span>
<h2
id="contact-canaux"
className="mt-1 font-headline text-xl font-extrabold tracking-tight text-primary md:text-2xl"
>
Me joindre ailleurs
</h2>
</div>
<div className="bg-white/80 rounded-md flex flex-col items-center space-y-4 mb-6">
<p className="text-blue-500 font-headline font-bold">
LinkedIn: Fernand Gras-Calvet
</p>
<p className="text-blue-500 font-headline font-bold">
Facebook: Fernand Gras-Calvet
</p>
<p className="text-blue-500 font-headline font-bold">
Email: grascalvet.fernand@gmail.com
</p>
</div>
<ul className="grid gap-3 md:grid-cols-3">
{canaux.map((c) => (
<li key={c.label}>
<Link
href={c.href}
target={c.external ? "_blank" : undefined}
rel={c.external ? "noopener noreferrer" : undefined}
className="group flex h-full items-center gap-3 rounded-tile bg-surface-container-low/80 px-4 py-3 transition-colors hover:bg-surface-container/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-on-primary">
<span
className="material-symbols-outlined"
aria-hidden="true"
translate="no"
>
{c.icon}
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
{c.label}
</span>
<span className="block truncate font-body text-sm text-on-surface">
{c.handle}
</span>
</span>
<span
className="material-symbols-outlined text-base text-primary transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
translate="no"
>
arrow_forward
</span>
</Link>
</li>
))}
</ul>
</section>
{/* Formulaire : carte vellum principale, le form occupe l'intérieur. */}
<section
className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
aria-labelledby="contact-form-title"
>
<div className="mb-5">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Formulaire
</span>
<h2
id="contact-form-title"
className="mt-1 font-headline text-xl font-extrabold tracking-tight text-primary md:text-2xl"
>
Écrire un message
</h2>
<p className="mt-1 font-body text-sm italic text-on-surface-variant">
Les trois champs sont obligatoires. Temps de réponse habituel : 48 h.
</p>
</div>
<div className="bg-white/50 p-6 rounded-lg border-b-4 border-blue-500 pb-2 shadow">
<ContactForm apiUrl={apiUrl} />
</div>
</section>
</div>
);
}
}

View File

@ -1,7 +1,7 @@
# Refonte visuelle — Direction "Digital Atelier"
**Créé :** 2026-04-22
**Statut :** en cours (étapes 1-7/8 terminées)
**Statut :** terminé — 8/8 étapes (2026-04-22)
**Source d'inspiration :** `stitch_V1/` (design newsletter Stitch — `DESIGN.md` et `code.html`).
**Audit préalable :** [`captures/AUDIT-VISUEL.md`](./captures/AUDIT-VISUEL.md).
@ -61,7 +61,7 @@ Chaque étape = un lot cohérent + éventuelle mise à jour de `captures/AUDIT-V
| 5 | Home : hero vellum, portrait frame, takeaways, pull-quote, CTAs | `app/page.tsx` | **fait** (2026-04-22) |
| 6 | Listes portfolio + compétences : grille asymétrique, cartes éditoriales | `app/portfolio/page.jsx`, `app/competences/page.jsx`, composants `Carousel*` | **fait** (2026-04-22) |
| 7 | Fiches détail + modale glossaire + GrasBot (jewel flottant) | `app/portfolio/[slug]/page.tsx`, `app/competences/[slug]/page.tsx`, `app/components/ModalGlossaire.tsx`, `app/components/ChatBot.js` | **fait** (2026-04-22) |
| 8 | Contact + Footer éditorial | `app/contact/page.js`, `app/components/ContactForm.tsx`, `app/components/Footer.jsx` | à faire |
| 8 | Contact + Footer éditorial | `app/contact/page.js`, `app/components/ContactForm.tsx`, `app/components/Footer.jsx` | **fait** (2026-04-22) |
## 4 bis. Correctif post-étape 3 (2026-04-22) — cohérence desktop/mobile
@ -306,6 +306,61 @@ Nouveau composant monté une seule fois dans `app/layout.tsx` → le chatbot est
- `ModalGlossaire` déjà aligné Stitch au correctif § 4 ter ; aucun changement nécessaire en 7, juste une vérification que son `CarouselCompetences` hérite bien de la lightbox 7.a — oui, c'est automatique.
- Persistance du fil de conversation GrasBot (refresh = historique perdu) : pas demandé, pas introduit. Ajouter un `localStorage` si besoin plus tard.
## 8. Étape 8 — Contact + Footer éditorial (2026-04-22)
Dernière page héritée d'avant refonte. Avant : `app/contact/page.js` utilisait `bg-white/50 rounded-md`, `border-b-4 border-blue-500 pb-2` sous les titres, et `ContactForm` affichait un formulaire `bg-white shadow-lg rounded-lg` bleu Tailwind (`bg-blue-500`). Incohérent avec le reste du site depuis l'étape 5 (tokens Stitch `primary = #26445d`, radius `sheet` / `tile`, ombres `shadow-ambient` / `shadow-jewel`).
### 8.a Page contact (`app/contact/page.js`)
Gabarit aligné sur les listes (étape 6) et le hero home (étape 5) :
- **Colonne utile `max-w-3xl`** (format "lettre", plus intime que les `max-w-6xl` des listes).
- **Hero éditorial vellum** : kicker uppercase tracking-[0.3em] « Contact · Prendre la parole » + titre Manrope extrabold `text-on-surface` « Correspondance » + pitch Newsreader `text-on-surface-variant`. Plus d'emoji `📬` dans le titre (cohérence avec les autres pages qui utilisent Material Symbols, pas des emojis).
- **Section « canaux directs »** : carte vellum principale avec titre secondaire (`text-primary`) puis grille 3 tuiles imbriquées `rounded-tile bg-surface-container-low/80 hover:bg-surface-container/80`. Chaque tuile = pastille primaire ronde avec Material Symbol (`link` pour LinkedIn, `public` pour Facebook, `alternate_email` pour email) + label kicker + handle tronqué + chevron `arrow_forward` qui se décale au hover. Mêmes codes visuels que les cartes vignette portfolio / compétences, sans l'aspect 2/3+1/3 (3 canaux = symétrie assumée).
- **Carte vellum principale pour le formulaire** : `rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8`, titre secondaire « Écrire un message » `text-primary` + pitch italique Newsreader « Temps de réponse habituel : 48 h ». Le form est rendu **sans** sa propre carte blanche (la carte parente suffit).
- Les **liens externes** (LinkedIn, Facebook) ont `target="_blank" rel="noopener noreferrer"` ; l'email ouvre un `mailto:` standard.
### 8.b ContactForm (`app/components/ContactForm.tsx`)
Refonte interne complète :
- **Suppression** du wrapper `bg-white shadow-lg rounded-lg` : le formulaire vit dans la carte vellum parente (page 8.a). Empêche le "double carton" incohérent avec la charte.
- **Labels visibles** en Manrope uppercase tracking-[0.3em] (au-dessus de chaque champ) — améliore l'accessibilité et aligne sur les kickers de la page. Les placeholders restent là à titre indicatif.
- **Champs** : `bg-surface-container-low/90`, `rounded-tile`, padding `px-4 py-3`, focus `focus-visible:ring-2 focus-visible:ring-primary`, placeholder en `text-on-surface-variant/70`. Le `textarea` gagne `min-h-[9rem] resize-y` (contrôle vertical par l'utilisateur, pas de hauteur figée gênante).
- **Bouton CTA jewel** : `bg-primary text-on-primary shadow-jewel hover:-translate-y-0.5 rounded-tile px-6 py-3 font-headline uppercase tracking-widest` + Material Symbol `send` (`translate="no"`). État `disabled` en `bg-outline-variant/60 text-on-surface-variant cursor-not-allowed` avec icône `hourglass_top`.
- **Feedback status** : plus de chaîne emoji `❌`/`✅`/`⏳` — un petit bandeau `rounded-tile` avec Material Symbol + texte, couleur selon l'état :
- `success``bg-primary-fixed/70 text-on-primary-fixed` + `check_circle`
- `error``bg-error-container text-on-error-container` + `error`
- `loading``bg-surface-container text-on-surface-variant` + `hourglass_top`
- **Accessibilité** : `role="status" aria-live="polite"` sur le bandeau, `autoComplete` (`name`, `email`), `noValidate` sur le form (on fait la validation en JS pour maîtriser les messages FR). L'ancien `isSuccess: boolean | null` à double état est remplacé par un `statusKind: "idle" | "loading" | "success" | "error"` unique, plus lisible.
### 8.c Footer (`app/components/Footer.jsx`)
Avant : `bg-white/50 rounded-lg backdrop-blur` + `text-gray-700`. Déjà partiellement migré (font-headline, `visite n°` en kicker) mais surface + radius hors charte.
Après : **carte vellum légère** centrée, sans ombre ambient (le footer ne doit pas flotter autant que le contenu principal) :
- Conteneur : `rounded-tile bg-surface-container-lowest/70 backdrop-blur-vellum px-6 py-5 text-center`.
- Trois lignes éditoriales :
1. **Signature** Manrope `text-primary` « Fernand Gras-Calvet » (identité).
2. **Pitch** Newsreader italic `text-on-surface-variant` « Portfolio — Étudiant 42 Perpignan · © {year} » (ton éditorial cohérent avec le hero home).
3. **Compteur** de visites Manrope `text-[10px] uppercase tracking-[0.3em] text-outline` (méta discrète).
- **SSR-safe** : `new Date().getFullYear()` est calculé côté client (via `useState` init + `useEffect`) pour éviter un mismatch SSR / CSR si l'année bascule pile à minuit.
### Ce que ça règle
- **Dernière page hors charte migrée** : le site est désormais 100 % « Digital Atelier » (home, layout, listes, fiches, glossaire, chatbot, contact, footer).
- **Cohérence typo** : plus aucune référence à `font-headline font-extrabold border-b-4 border-blue-500 pb-2` (motif ancien).
- **Cohérence iconographique** : plus aucun emoji `📬 📩 🚀` résiduel dans les titres de page contact / form ; tout est passé en Material Symbols (seuls emojis acceptés = message utilisateur dans le chatbot, et l'emoji `📅` dans `sendMessage` qui reste un détail de payload côté Strapi, pas d'affichage direct).
- **Accessibilité contact** : labels visibles, `role="status"`, `aria-live="polite"`, `autoComplete` — améliore l'usage clavier / lecteur d'écran.
- **Footer** : plus de double lecture (`text-gray-700` sur `bg-white/50` contrastait mal sur wallpaper clair) — `text-on-surface-variant` sur vellum reste lisible partout.
### Points laissés en dehors de l'étape 8
- **Persistance du compteur de visites** côté serveur (Strapi) : hors scope refonte visuelle. Reste en `localStorage` comme avant.
- **Validation serveur des champs du form** (anti-spam, honeypot, reCAPTCHA) : hors scope refonte visuelle. Strapi ne filtre pour l'instant que sur la structure JSON attendue.
- **Fusion Carousel.tsx / CarouselCompetences.tsx** : reste en dette technique (déjà noté §7).
## 5. Checklist relecture (à passer à la fin de chaque étape)
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).

View File

@ -100,9 +100,9 @@ Les captures suivantes **nont pas révélé de problème spécifique** après
| 14 | Compétences fiche mobile | `/competences/slug` | `14-competences-detail-ia-mobile.webp` | `OK` |
| 15 | GrasBot ouvert desktop | `/competences/slug` | `15-competences-grasbot-ouvert-desktop.webp` | `OK` |
| 16 | Glossaire modal desktop | `/competences/slug` | `16-competences-glossaire-ouvert-desktop.webp` | `OK` |
| 17 | Contact formulaire desktop | `/contact` | `17-contact-formulaire-desktop.webp` | `OK` |
| 18 | Contact formulaire mobile | `/contact` | `18-contact-formulaire-mobile.webp` | `OK` |
| 19 | Footer desktop | `/` | `19-layout-footer-desktop.webp` | `OK` |
| 17 | Contact formulaire desktop | `/contact` | `17-contact-formulaire-desktop.webp` | `fait` (étape 8, 2026-04-22) |
| 18 | Contact formulaire mobile | `/contact` | `18-contact-formulaire-mobile.webp` | `fait` (étape 8, 2026-04-22) |
| 19 | Footer desktop | `/` | `19-layout-footer-desktop.webp` | `fait` (étape 8, 2026-04-22) |
| 20 | Compteur visites desktop | `/` | `20-layout-compteur-visites-desktop.webp` | `OK` |
| 21 | Admin messages desktop | `/admin/messages` | `21-admin-messages-desktop.webp` | `OK` |
@ -110,4 +110,6 @@ Les captures suivantes **nont pas révélé de problème spécifique** après
## Suite
Passage à la **refonte visuelle globale** : direction artistique (palette, typographie, rythme vertical), hiérarchie des pages, traitement des cartes portfolio / compétences, header et footer. À cadrer avec lutilisateur avant toute modification.
- **Étape 8 Digital Atelier bouclée** (2026-04-22) : contact + formulaire + footer migrés à la charte Stitch (voir `docs-site-interne/REFONTE-VISUELLE.md §8`).
- Prendre de **nouvelles captures** 17 / 18 / 19 pour figer le rendu post-refonte (remplacement des WebP existants dans `docs-site-interne/captures/`).
- Dette technique identifiée pour une future passe : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites, validation anti-spam du formulaire.