# Audit — Footer & Newsletter (`feature/home-best-practices-audit`)

Périmètre lu : `src/components/Footer.tsx`, `src/components/footer/NewsletterForm.tsx`, `src/app/actions/newsletter.ts`, `src/components/blog/JournalNewsletterBand.tsx`, `src/config/site.ts`, `src/app/[locale]/layout.tsx`, `messages/{en,fr}.json` (clés `Footer.*`), + référentiels `AGENTS.md`, `docs/nextjs-16.x.md`, `docs/brand.md`, `src/app/globals.css`, skills shadcn/react-best-practices/a11y/zod/seo/tailwind.

**Deux corrections factuelles au brief avant de commencer :**
1. **`lucide-react@1.25.0` (version installée) ne contient AUCUNE icône de marque.** Vérifié : `node_modules/lucide-react/dist/esm/icons/` = 3994 icônes, zéro `linkedin`/`facebook`/`instagram`/`twitter`/`github`/`youtube`. Lucide a retiré les brand icons. `import { Linkedin } from "lucide-react"` **ne compilera pas**.
2. `pt-14 lg:pt-16`, `mb-12`, `pt-9`, `pb-7`, `mt-10`, `gap-3.5`, `size-11`, `py-3.5`, `px-0.5` **ne sont pas des valeurs arbitraires** — ce sont des pas de l'échelle Tailwind par défaut. Rien à corriger dessus (et `size-11` = 44px = cible tactile WCAG 2.5.5 AAA : c'est bien).

---

## 1. Tableau des constats

| # | Sévérité | Axe | Fichier:ligne | Constat | Correction (avant → après) | Effort | Quick win |
|---|---|---|---|---|---|---|---|
| 1 | **Critique** | 4 | `NewsletterForm.tsx:20` + `layout.tsx:115` + `blog/page.tsx:104` | **`id="newsletter-email"` en dur, et le composant est monté 2× sur `/blog`** (Footer dans le layout + `JournalNewsletterBand`). HTML invalide : deux `id` identiques, deux `htmlFor` ambigus → cliquer le label du bandeau focalise l'input du **footer**. | `id="newsletter-email"` → `const id = useId()` ; `id={id}` / `htmlFor={id}` | S | ✅ |
| 2 | **Majeur** | 3 | `NewsletterForm.tsx:30` | `hover:bg-burnished-gold hover:text-white` → blanc `#ffffff` sur `#c5a059` = **2.46:1**. Échec WCAG 1.4.3 (min 4.5:1) sur l'état hover du CTA principal. | `hover:text-white` → `hover:text-foreground` (`#1a1c1b` sur `#c5a059` = **6.98:1**) | S | ✅ |
| 3 | **Majeur** | 3 | `Footer.tsx:116` | Copyright `text-on-inverse-surface/45` sur `bg-foreground` = **4.08:1** en 10px. Échec WCAG 1.4.3. | `/45` → `/60` (6.26:1) ou `/55` (5.47:1) | S | ✅ |
| 4 | **Majeur** | 3 | `NewsletterForm.tsx:36-45` | Les `<p aria-live="polite">` **n'existent pas** avant le changement d'état. Un nœud inséré *avec* `aria-live` n'est pas annoncé de façon fiable (NVDA/JAWS/VoiceOver) : la région doit être présente au moment où son contenu change. Aucun succès/erreur n'est annoncé. | Conteneur permanent : `<div role="status" aria-live="polite" aria-atomic="true" className="mt-4 min-h-[1.5rem] text-sm">{state.status === "success" ? … : state.status === "error" ? … : null}</div>` | S | ✅ |
| 5 | **Majeur** | 3 | `NewsletterForm.tsx:19-26` | `<input>` sans `autoComplete="email"`, sans `required`, sans `inputMode="email"`, sans `aria-invalid`, sans `aria-describedby` pointant le message d'erreur. Aucune validation navigateur : un champ vide part au serveur. | Ajouter `required autoComplete="email" inputMode="email" aria-invalid={state.status === "error"} aria-describedby={statusId}` | S | ✅ |
| 6 | **Majeur** | 3 | `Footer.tsx:105` | `<nav>` sans `aria-label`. Deux landmarks `navigation` sur chaque page (header + footer) sont indiscernables au lecteur d'écran (WCAG 1.3.1 / technique ARIA11). | `<nav aria-label={t("nav.label")}>` → nouvelle clé `Footer.nav.label` : "Footer" / "Pied de page" | S | ✅ |
| 7 | **Majeur** | 1 | `NewsletterForm.tsx:1-11` | `"use client"` couvre **tout** le formulaire : `<form>`, `<label>`, `<input>`, `<button>`, plus `useTranslations` → les 4 chaînes i18n + tout le markup partent en JS client alors qu'ils sont 100 % statiques. | Voir §3 : `Footer` (serveur) rend `<form action={subscribeNewsletter}>` … non, `useActionState` impose un client à la racine du form → découper en `NewsletterForm` (client, minimal) + `SubmitButton` (`useFormStatus`) + passer les libellés **en props depuis le serveur** | M | — |
| 8 | **Majeur** | 1 | `layout.tsx:112` | `<NextIntlClientProvider>` **sans prop `messages`** → next-intl sérialise l'intégralité de `messages/en.json` (**49,5 Ko**) / `fr.json` (**54,7 Ko**) dans le payload RSC de **chaque** page. `NewsletterForm.tsx:10` est l'un des 12 consommateurs client qui l'imposent. | Passer les libellés en props depuis les Server Components, puis scoper : `<NextIntlClientProvider messages={pick(messages, ["Common", "Header", …])}>` | L | — |
| 9 | **Majeur** | 5 (sécu) | `newsletter.ts:15-29` | La Server Action **ne fait rien** : elle valide puis `return { status: "success" }`. L'email est **jeté**. Aucun rate-limit, aucun honeypot, aucun CAPTCHA. C'est un endpoint POST public, invocable directement, exécutable en boucle → surface de DoS gratuite, et l'UI ment à l'utilisateur ("Thank you for subscribing"). Le skill `server-auth-actions` classe ce risque **CRITICAL**. | Court terme : rate-limit par IP (`headers()` → `x-forwarded-for`, LRU/Upstash), champ honeypot `name="company"` rejeté si rempli, `.max(254)`. Moyen terme : brancher l'ESP ou afficher un état "coming soon" honnête. | M | — |
| 10 | **Majeur** | 3 (RGPD) | `Footer.tsx:104-119` | On collecte une donnée personnelle **sans** case de consentement ni lien vers une politique de confidentialité, et le footer ne contient **aucun lien légal** (Privacy / Legal notice / Terms). | Ajouter une ligne légale dans le bloc `Legal` + `<p>` de consentement sous l'input avec lien `/privacy-policy` | M | — |
| 11 | **Majeur** | 3 (SEO) | `Footer.tsx:14-21` vs `Header.tsx:27-35` | Maillage interne amputé : le footer expose **6** liens, le header **8**. Absents du footer : `/design-development`, `/gallery`, `/news-events` — trois pages pourtant dans le `sitemap.ts:13-22`. Le footer est le seul bloc de liens présent sur **toutes** les pages : c'est le principal vecteur de PageRank interne. | Aligner sur `primaryNav` + `ctaNav` (8 entrées) | S | ✅ |
| 12 | **Majeur** | 3 (SEO) | `messages/en.json` `Footer.nav.*` vs `Header.nav.*` | Libellés divergents pour la même URL : `sustainability` = "Sustainability" (footer) vs **"Certification & Sustainability"** (header) ; `production` = "Production Process" vs **"Production"**. Ancres incohérentes = signal de pertinence dilué. FR idem ("Durabilité" vs …). | Réutiliser `Header.nav.*` dans le footer et supprimer `Footer.nav.*` (sauf `home`) | S | ✅ |
| 13 | **Majeur** | 1 | `Footer.tsx:44` | `new Date().getFullYear()` dans un Server Component **statique** (pas de `cacheComponents` dans `next.config.ts`, pages prerendues via `generateStaticParams`) → l'année est **figée au build**. Un site buildé en 2026 affichera "© 2026" en janvier 2028. | 3 options : (a) supprimer l'année (`"© Advance Manufacturers"`) — le plus simple et le plus robuste ; (b) `<span suppressHydrationWarning>` + micro-client ; (c) revalidation planifiée. **Recommandation : (a)**. | S | ✅ |
| 14 | **Majeur** | 4 | `Footer.tsx:64-78` vs `BrandLogo.tsx:16-27` | **Wordmark dupliqué** en deux implémentations divergentes : `text-[19px]`/`mt-[3px]`/`text-[9.5px]`/`text-foreground`/`text-brushed-steel` (header) vs `text-[20px]`/`mt-1`/`text-[10px]`/`text-white`/`/55` (footer). Nommage accessible divergent aussi : `aria-label` sur le `<Link>` (header) vs `<span className="sr-only">` (footer). Et les chaînes sont dupliquées en i18n (`Header.brand.primary` == `Footer.brand.primary`). | Extraire `src/components/Wordmark.tsx` : `<Wordmark tone="light" \| "dark" />`, `aria-label` unique, une seule clé i18n (`Brand.primary`/`Brand.secondary`) | M | — |
| 15 | **Majeur** | 4 | `Footer.tsx:30-39, 95-97` | Les "icônes" sociales sont des **glyphes texte** (`"in"`, `"f"`, `"ig"`) avec trois tailles de police ad hoc pour compenser l'absence de métriques communes. Rendu dépendant de la police, non alignable, faux à l'œil. | Voir §6 (branchement des SVG client) | M | — |
| 16 | **Majeur** | 2 | `Footer.tsx:93` | `rounded-full` sur les pastilles sociales. `docs/brand.md` § *Shapes* : « The shape language is strictly **Sharp (0px)**. Every UI element … uses 90-degree corners. » Et `--radius: 0px` (`globals.css:145`). Seule entorse au design system du footer. | `rounded-full` → (rien) ; garder `size-11 border` | S | ✅ |
| 17 | **Majeur** | 2 | tout `Footer.tsx` + `NewsletterForm.tsx` | **26 valeurs arbitraires** (voir §2), dont **10 tailles de police distinctes** (10, 10.5, 11, 13, 14.5, 15, 16, 20, 28, 34 px) et **5 `tracking`** dans un seul footer. Aucun token `--text-*` n'existe dans `globals.css` (`@theme` n'en déclare aucun). Systémique : **25+ tailles arbitraires distinctes** dans `src/`, `max-w-[1440px]` répété **35 fois**. | Créer une échelle typographique dans `@theme` + `@utility container-editorial` (voir §2) | L | — |
| 18 | Mineur | 4 | `blog/page.tsx:104` + `layout.tsx:115` | **Deux formulaires newsletter sur `/blog`** : le bandeau `JournalNewsletterBand` puis le footer, 20 cm plus bas. Deux `<h2>` d'inscription, deux CTA identiques, un seul objectif. Cannibalisation UX + dilution du signal sémantique. | Soit masquer le bloc newsletter du footer sur `/blog`, soit supprimer le bandeau. Prérequis : corriger #1 d'abord. | M | — |
| 19 | Mineur | 4 | `JournalNewsletterBand.tsx:21` → `NewsletterForm.tsx:10` | Couplage de namespace : un composant `blog/` réutilise un composant `footer/` qui lit `Footer.newsletter.*`. Les libellés du bandeau blog viennent donc du namespace *Footer*. | Déplacer les clés vers `Newsletter.*` et passer les libellés en props (converge avec #7) | M | — |
| 20 | Mineur | 3 | `NewsletterForm.tsx:16-23` | `<label className="sr-only">` **et** `aria-label` sur l'input : deux noms accessibles concurrents, `aria-label` gagne et neutralise le `<label>`. Le `placeholder` répète en plus le même texte ("Email Address"). | Supprimer `aria-label` (ligne 23), garder le `<label sr-only>` | S | ✅ |
| 21 | Mineur | 3 | `Footer.tsx:93` | `border-on-inverse-surface/35` sur `bg-foreground` = **2.99:1**. Sous le seuil 3:1 de WCAG 1.4.11 (contraste des composants non textuels) — d'un cheveu. | `/35` → `/45` (4.08:1) | S | ✅ |
| 22 | Mineur | 3 | `NewsletterForm.tsx:25` | `focus:outline-none` sans indicateur de remplacement explicite (seule la couleur de bordure change, `/40` → 100 %), et en `focus:` et non `focus-visible:` → l'indicateur se déclenche aussi à la souris. | `focus:` → `focus-visible:` + ajouter `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-background` cohérent avec le bouton (l. 30) | S | ✅ |
| 23 | Mineur | 2 | `Footer.tsx:51,66` ; `NewsletterForm.tsx:25,30` | `text-white` brut alors que le token du texte sur surface sombre est `--color-on-inverse-surface` (`#f1f1ef`). Violation directe de `shadcn/rules/styling.md` § *Semantic colors*. | `text-white` → `text-on-inverse-surface` | S | ✅ |
| 24 | Mineur | 2 | `Footer.tsx:61,104` | Deux `border-t border-on-inverse-surface/15` manuels. `shadcn/rules/composition.md` : « Use `Separator` instead of raw `hr` or border divs ». | `bunx shadcn@latest add separator` puis `<Separator className="bg-on-inverse-surface/15" />` — ou, si l'on assume la divergence, extraire une utility `@utility hairline-inverse` | S | ✅ |
| 25 | Mineur | 5 (Zod) | `newsletter.ts:5,8` | Regex email maison `/^[^@\s]+@[^@\s]+\.[^@\s]+$/` au lieu de l'API Zod. Zod v4 (`zod@^4.4.3` installé) expose `z.email()` **au niveau racine** — `z.string().email()` est déprécié. Aucun `.max()` → payload non borné. | `z.string().trim().min(1).regex(EMAIL_RE)` → `z.email({ error: "invalid" }).trim().toLowerCase().max(254)` | S | ✅ |
| 26 | Mineur | 1 | `NewsletterForm.tsx:29-33` vs `ContactForm.tsx:86` | Incohérence : `ContactForm` affiche `t("sending")` pendant le submit, `NewsletterForm` se contente de `disabled:opacity-60`. Aucune clé `Footer.newsletter.sending` n'existe. Et le champ n'est pas vidé après succès (pas de `key`/reset). | Ajouter `sending` en i18n + `{pending ? t("sending") : t("submit")}` ; `<form key={state.status === "success" ? "sent" : "idle"}>` pour reset | S | ✅ |
| 27 | Mineur | 3 (SEO) | `Footer.tsx` (absent) | `site.contact` (adresse complète Bangkok, tél, fax, email — `site.ts:39-54`) est exploité par le JSON-LD (`structured-data.ts:57-67`) mais **jamais rendu en HTML visible** dans le footer. Le NAP visible sur toutes les pages est un signal SEO local classique, aujourd'hui inexploité. | Ajouter un bloc `<address>` NAP dans la colonne marque | M | — |
| 28 | Mineur | 3 (SEO) | `Footer.tsx:91` | `rel="noopener noreferrer"` sur ses **propres** profils : `noreferrer` supprime le referrer, on perd l'attribution du trafic sortant dans les analytics des réseaux. Et `rel="me"` (relation d'identité) est absent. | `rel="noopener noreferrer"` → `rel="me noopener"` (`noopener` seul suffit à la sécurité) | S | ✅ |
| 29 | Info | 2 | `src/components/ui/` | Aucun de `field`, `input`, `input-group`, `button` (au sens shadcn form) n'est installé — seuls `breadcrumb`, `button`, `select` existent. `shadcn/rules/forms.md` impose `FieldGroup`+`Field`, et `InputGroup`+`InputGroupAddon` pour un bouton dans un input. | Voir §7 (recommandation nuancée) | M | — |
| 30 | Info | 2 | `next.config.ts:22-33` | `lucide-react` est une dépendance **utilisée** (`ui/breadcrumb.tsx:7`, `ui/select.tsx:7`) mais **absente** d'`experimental.optimizePackageImports`, alors que `@tabler/icons-react` y figure sans être importé nulle part dans `src/`. Violation de `docs/nextjs-16.x.md` §2 (« obligatoire pour toute nouvelle dépendance », « Aucune exception »). Devient bloquant si on ajoute des icônes. | Ajouter `"lucide-react"` ; retirer `@tabler/icons-react` du config **et** de `package.json` (candidat `knip`) | S | ✅ |
| 31 | Info | 2 | `Footer.tsx:47` | `bg-foreground` (`#1a1c1b`) associé à `text-on-inverse-surface`. La paire sémantique de `--color-on-inverse-surface` est `--color-inverse-surface` (`#2f3130`), pas `--foreground`. Le rendu est correct mais l'appariement de tokens ment. | `bg-foreground` → `bg-inverse-surface`, **ou** documenter que le footer est volontairement plus sombre | S | — |
| 32 | Info | 4 | `JournalNewsletterBand.tsx:18` | `text-on-inverse-surface/[.72]` : syntaxe d'opacité arbitraire (`/[.72]`) alors que tout le footer utilise la forme courte (`/70`, `/60`…). Incohérence de style pour 2 % de différence invisible. | `/[.72]` → `/70` | S | ✅ |
| 33 | Info | 3 | `e2e/home.spec.ts:37` | Le test e2e s'accroche au texte littéral `"Join Our Newsletter List"` (`Footer.newsletter.title`). Toute réécriture SEO du titre casse la suite. | Cibler le landmark : `page.getByRole("contentinfo").getByRole("heading", { level: 2 })` | S | ✅ |

---

## 2. Valeurs arbitraires — inventaire exhaustif et remplacement

Aucun token `--text-*`, `--tracking-*` ou `--leading-*` n'existe dans `globals.css` : `@theme` (l. 7-65) ne déclare que des couleurs, `--spacing-*`, `--font-*` et `--breakpoint-nav`. **Tout remplacement suppose donc de créer l'échelle d'abord.**

### 2a. Tailles de police (10 distinctes dans le footer)

| Fichier:ligne | Valeur actuelle | Rôle | Token proposé |
|---|---|---|---|
| `Footer.tsx:116` | `text-[10px]` | copyright | `--text-micro: 0.625rem` → `text-micro` |
| `Footer.tsx:72` | `text-[10px]` | wordmark secondaire | `text-micro` (via `<Wordmark>`) |
| `Footer.tsx:110` | `text-[10.5px]` | liens nav footer | **fusionner avec 10px** → `text-micro` (0,5 px d'écart invisible) |
| `Footer.tsx:35` | `text-[11px]` | glyphe "in" | supprimé avec les vraies icônes (#15) |
| `NewsletterForm.tsx:30` | `text-[11px]` | libellé bouton | `--text-label: 0.6875rem` → `text-label` (36 occurrences dans `src/` : le plus rentable) |
| `Footer.tsx:38` | `text-[13px]` | glyphe "ig" | supprimé (#15) |
| `Footer.tsx:80` | `text-[14.5px]` | description marque | **fusionner** → `text-sm` (0.875rem = 14px) |
| `Footer.tsx:37` | `text-[15px]` | glyphe "f" | supprimé (#15) |
| `NewsletterForm.tsx:25` | `text-[15px]` | saisie input | `--text-input: 0.9375rem` → `text-input` (10 occurrences dans `src/`) |
| `Footer.tsx:54` | `text-base` | corps newsletter | ✅ déjà sur l'échelle |
| `Footer.tsx:66` | `text-[20px]` | wordmark principal | encapsulé dans `<Wordmark>` (#14) |
| `Footer.tsx:51` | `text-[28px]` / `lg:text-[34px]` | h2 newsletter | `--text-h2: 1.75rem` + `--text-h2-lg: 2.125rem` → `text-h2 lg:text-h2-lg` (6 et 8 occurrences dans `src/`) |

### 2b. `tracking` (5 distincts)

| Fichier:ligne | Valeur | Token proposé |
|---|---|---|
| `Footer.tsx:35` | `tracking-[0.05em]` | supprimé avec les glyphes (#15) |
| `Footer.tsx:110`, `Footer.tsx:116` | `tracking-[0.14em]` | `--tracking-label: 0.15em` → `tracking-label` — **fusionner** avec les 14× `[0.16em]`, 9× `[0.14em]`, 6× `[0.15em]`, 8× `[0.18em]`, 1× `[0.12em]` du reste du repo → 2 valeurs suffisent (`label` 0.15em, `label-wide` 0.18em) |
| `NewsletterForm.tsx:30` | `tracking-[0.16em]` | `tracking-label` |
| `Footer.tsx:66` | `tracking-[0.32em]` | encapsulé dans `<Wordmark>` |
| `Footer.tsx:72` | `tracking-[0.42em]` | encapsulé dans `<Wordmark>` |

### 2c. `leading` (3 distincts)

| Fichier:ligne | Valeur | Remplacement |
|---|---|---|
| `Footer.tsx:51` | `leading-[1.18]` / `lg:leading-[1.15]` | `--leading-display: 1.15` → `leading-display` (une seule valeur suffit, 0.03 d'écart) |
| `Footer.tsx:54` | `leading-[1.65]` | `leading-relaxed` (= 1.625, écart 0.025 invisible) |
| `Footer.tsx:80` | `leading-[1.7]` | `leading-relaxed` |

### 2d. Largeurs et gaps

| Fichier:ligne | Valeur | Remplacement |
|---|---|---|
| `Footer.tsx:48` | `max-w-[1440px]` | **35 occurrences dans `src/`** → `@utility container-editorial { max-inline-size: 1440px; margin-inline: auto; }` (mieux : y intégrer `px-6 lg:px-16`, également répété partout) |
| `Footer.tsx:50` | `max-w-[640px]` | `max-w-2xl` (=42rem=672px) ou `--container-prose-lg` |
| `Footer.tsx:62` | `max-w-[520px]` | `max-w-lg` (=32rem=512px) |
| `NewsletterForm.tsx:14` | `lg:max-w-[560px]` | `lg:max-w-xl` (=36rem=576px) — ou **supprimer** : la contrainte de largeur appartient au parent, pas au formulaire (c'est ce qui le rend inadapté dans `JournalNewsletterBand`) |
| `NewsletterForm.tsx:15` | `gap-[22px]` | `gap-5` (20px) ou `gap-6` (24px) |
| `NewsletterForm.tsx:15` | `sm:gap-[18px]` | `sm:gap-4` (16px) |
| `NewsletterForm.tsx:25` | `sm:min-w-[240px]` | `sm:min-w-60` (=15rem=240px) — **valeur identique, sur l'échelle** |

### 2e. Opacités ad hoc (8 valeurs distinctes)

`/15` (l. 61, 104), `/35` (l. 93), `/40` (`NewsletterForm:25`), `/45` (l. 116), `/50` (`NewsletterForm:25`), `/55` (l. 72), `/60` (l. 80), `/70` (l. 54, 110). Huit paliers pour un seul bloc, dont deux échouent en contraste (#3, #21).

**Remplacement : trois tokens sémantiques dérivés, opaques (pas d'alpha) et donc auditables une fois pour toutes :**
```css
@theme {
  --color-on-inverse-strong:  #f1f1ef; /* titres, saisie          — 15.4:1 */
  --color-on-inverse-muted:   #9b9b99; /* corps, liens, copyright — 6.3:1  */
  --color-on-inverse-hairline:#4a4c4b; /* filets, bordures        — 3.4:1  */
}
```
→ `/70`, `/60`, `/55`, `/45` deviennent `text-on-inverse-muted` ; `/15`, `/35`, `/40` deviennent `border-on-inverse-hairline` ; `text-white` (#23) devient `text-on-inverse-strong`.

---

## 3. Frontière client/serveur — actuel vs cible

### Actuel

```
layout.tsx (RSC, statique)
└─ NextIntlClientProvider          ← ⚠️ pas de prop `messages` : 49,5 Ko (en) / 54,7 Ko (fr)
   │                                  sérialisés dans le payload RSC de CHAQUE page
   └─ Footer                       ✅ RSC — useTranslations("Footer") côté serveur
      ├─ <h2>, <p>, wordmark,
      │  liens sociaux, nav, ©     ✅ RSC, zéro JS
      │  └─ new Date().getFullYear()  ⚠️ figé au build (pas de cacheComponents)
      └─ NewsletterForm            ❌ "use client" sur TOUT le formulaire
         ├─ useTranslations(...)   ❌ force les messages i18n dans le bundle client
         ├─ useActionState         ✅ légitimement client
         ├─ <label> <input>        ❌ markup statique inutilement hydraté
         ├─ <button disabled={pending}>  ⚠️ n'a besoin que de `pending`
         └─ messages de statut     ⚠️ n'ont besoin que de `state`
```

### Cible

```
Footer (RSC)
└─ <NewsletterForm
     labels={{ email, placeholder, submit, sending, success, error }}  ← strings, pas de hook client
   />
   └─ NewsletterForm ("use client", ~1,2 Ko)
      ├─ useActionState(subscribeNewsletter, INITIAL)   ← irréductiblement client
      ├─ useId()                                        ← corrige #1
      ├─ <label htmlFor={id}> <input id={id} …>         (markup, pas de hook)
      ├─ <SubmitButton label={…} pendingLabel={…} />    ("use client", useFormStatus)
      └─ <FormStatus state={state} labels={…} />        (role="status" TOUJOURS monté)
```

**Verdict honnête sur la question « le `<label>`/`<input>`/bouton pourraient-ils rester serveur ? » : non.** `useActionState` doit vivre à la racine du `<form>` pour brancher `action={action}` et lire `state` — donc l'arbre entier est déjà dans un Client Component, et l'extraire d'un `SubmitButton`/`FormStatus` **ne réduit pas** le nombre de composants clients. Le gain réel est ailleurs :

1. **`useFormStatus` ne sert que si l'on abandonne `useActionState`** au profit d'un `<form action={serverAction}>` nu. C'est possible ici (l'action ne renvoie qu'un statut binaire), mais on perd l'affichage du message d'erreur sans redirect. **Recommandation : garder `useActionState`, ajouter `SubmitButton`/`FormStatus` uniquement pour la lisibilité, pas pour la perf.**
2. **Le vrai gain de bundle est #8** : supprimer `useTranslations` de `NewsletterForm` (props depuis le serveur) est le **prérequis** pour pouvoir un jour scoper `NextIntlClientProvider messages={…}` et retirer ~50 Ko du payload de chaque page. C'est le seul levier à impact mesurable de tout cet axe.
3. **`new Date().getFullYear()`** : la page est prerendue au build (`generateStaticParams` l. 36-38, pas de `cacheComponents` dans `next.config.ts`). L'année est donc gravée dans le HTML. **Alternative recommandée : supprimer l'année** — juridiquement inutile, techniquement infaillible, zéro JS. Les alternatives (client component + `suppressHydrationWarning`, ou `'use cache'` + `cacheLife("days")`) coûtent respectivement du JS et du recalcul pour zéro bénéfice utilisateur.

---

## 4. Inventaire alt / aria

Le footer ne contient **aucun `<Image>`, `<img>` ni `<svg>`**. Rien à optimiser côté `next/image`.

| Élément | Fichier:ligne | Mécanisme actuel | Nom accessible EN | Nom accessible FR | Nature | Verdict |
|---|---|---|---|---|---|---|
| `<footer>` | `Footer.tsx:47` | landmark implicite `contentinfo` (enfant direct de `<body>` via `layout.tsx:115`) | — | — | landmark | ✅ |
| Wordmark ligne 1 | `Footer.tsx:64-69` | `<span aria-hidden="true">` — **texte**, pas un logo | masqué | masqué | décoratif | ✅ |
| Wordmark ligne 2 | `Footer.tsx:70-75` | `<span aria-hidden="true">` | masqué | masqué | décoratif | ✅ |
| Nom du lien home | `Footer.tsx:76-78` | `<span className="sr-only">` | "Advance Manufacturers" | "Advance Manufacturers" | fonctionnelle | ⚠️ divergent de `BrandLogo.tsx:11` qui utilise `aria-label` — unifier (#14) |
| Lien LinkedIn | `Footer.tsx:87-98` | `aria-label={t("social.linkedin")}` + glyphe `"in"` `aria-hidden` | "LinkedIn" | "LinkedIn" | fonctionnelle | ✅ nom / ❌ visuel (#15) |
| Lien Facebook | idem | `aria-label` + glyphe `"f"` `aria-hidden` | "Facebook" | "Facebook" | fonctionnelle | ✅ / ❌ |
| Lien Instagram | idem | `aria-label` + glyphe `"ig"` `aria-hidden` | "Instagram" | "Instagram" | fonctionnelle | ✅ / ❌ |
| `<nav>` footer | `Footer.tsx:105` | aucun | — | — | landmark | ❌ `aria-label` manquant (#6) |
| `<h2>` newsletter | `Footer.tsx:51-53` | texte visible | "Join Our Newsletter List" | "Rejoignez notre newsletter" | structure | ✅ (mais couplé au e2e, #33) |
| `<label>` email | `NewsletterForm.tsx:16-18` | `sr-only` + `htmlFor` | "Email Address" | "Adresse e-mail" | étiquette | ⚠️ neutralisé par `aria-label` l. 23 (#20) |
| `<input>` | `NewsletterForm.tsx:19-26` | `aria-label` redondant | "Email Address" | "Adresse e-mail" | saisie | ❌ (#5, #20, #22) |
| Message succès | `NewsletterForm.tsx:37-39` | `aria-live="polite"` sur nœud conditionnel | "Thank you for subscribing." | "Merci pour votre inscription." | statut | ❌ non annoncé (#4) |
| Message erreur | `NewsletterForm.tsx:42-44` | idem | "Please enter a valid email address." | "Veuillez saisir une adresse e-mail valide." | statut | ❌ (#4) — et devrait être `role="alert"` |

---

## 5. Doublons

| Doublon | Emplacements | Impact |
|---|---|---|
| **Formulaire newsletter** | `layout.tsx:115` (Footer) + `blog/page.tsx:104` (`JournalNewsletterBand.tsx:21`) — **même composant, mêmes clés i18n `Footer.newsletter.*`** | ❌ **`id` DOM dupliqué sur `/blog`** (#1, critique) + 2 CTA identiques + 2 `<h2>` d'inscription |
| **Wordmark** | `BrandLogo.tsx:16-27` et `Footer.tsx:64-78` | Deux implémentations divergentes (#14) |
| **Chaînes de marque i18n** | `Header.brand.primary/secondary` et `Footer.brand.primary/secondary` — **valeurs identiques** en EN et FR | Duplication de traduction, risque de dérive |
| **Libellés de nav i18n** | `Header.nav.*` et `Footer.nav.*` pour les mêmes URLs, **avec des libellés différents** (#12) | Signal SEO dilué |
| **Regex email** | `newsletter.ts:5` et `contact-us/actions.ts:5` — `EMAIL_RE` copié-collé à l'identique | À remplacer par `z.email()` dans les deux (#25) |
| **Pattern de formulaire** | `NewsletterForm.tsx` et `ContactForm.tsx` — même structure `useActionState`/`status`/`p` conditionnel, avec les **mêmes trois défauts** (`aria-live` mal placé ou absent, pas d'`autoComplete`, `aria-label` redondant sur l'input) | Corriger les deux ensemble |
| **`@tabler/icons-react`** | Dans `package.json` **et** `optimizePackageImports`, **importé nulle part** dans `src/` | Dépendance morte (#30) |

---

## 6. Icônes sociales — quelle voie pour les SVG du client

**Contrainte bloquante d'abord : `lucide-react@1.25.0` n'a aucune icône de marque.** Les 3994 fichiers de `node_modules/lucide-react/dist/esm/icons/` ne contiennent ni `linkedin`, ni `facebook`, ni `instagram` (ni `twitter`, `github`, `youtube`). Lucide a retiré les brand icons pour raisons de marque déposée. Cette option est morte.

| Option | Verdict | Raison |
|---|---|---|
| `lucide-react` | ❌ **impossible** | Icônes inexistantes dans la version installée |
| `simple-icons` / `@icons-pack/react-simple-icons` | ⚠️ déconseillé | +1 dépendance et +1 entrée `optimizePackageImports` obligatoire (`docs/nextjs-16.x.md` §2) pour 3 icônes. Surtout : le client **fournit ses propres SVG**, on n'utiliserait donc jamais le paquet. |
| **Composants SVG locaux** | ✅ **recommandé** | Zéro dépendance, zéro config `next.config.ts`, tree-shaking natif, coloration par `currentColor` (`hover:text-foreground` de la ligne 93 continue de marcher), et c'est le seul moyen d'utiliser exactement les fichiers du client. `docs/brand.md` + mémoire projet : « icônes en SVG ». |
| `<Image src="/icons/linkedin.svg">` | ❌ | Une requête réseau par icône, pas de `currentColor`, `next/image` ne sait pas optimiser du SVG. |

**Implémentation suggérée** — `src/components/icons/social.tsx`, un fichier, trois exports :

```tsx
// Pas de "use client" : ce sont des RSC purs.
type IconProps = { className?: string };

export function LinkedInIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
         focusable="false" className={className}>
      <path d="…" />   {/* SVG fourni par le client, passé par SVGO */}
    </svg>
  );
}
// FacebookIcon, InstagramIcon idem
```

Et dans `Footer.tsx`, remplacer `glyph`/`glyphClassName` (l. 26-27, 34-38) par un composant — conforme à `shadcn/rules/icons.md` § *Pass icons as component objects, not string keys* :

```tsx
type SocialItem = {
  key: "linkedin" | "facebook" | "instagram";
  url: string;
  Icon: ComponentType<{ className?: string }>;   // ← pas une string
};

const socialLinks = [
  { key: "linkedin",  url: site.social.linkedin,  Icon: LinkedInIcon },
  { key: "facebook",  url: site.social.facebook,  Icon: FacebookIcon },
  { key: "instagram", url: site.social.instagram, Icon: InstagramIcon },
] as const satisfies ReadonlyArray<SocialItem>;
```

Le rendu devient `<social.Icon className="size-4" />` — une seule taille au lieu des trois tailles de police ad hoc (`text-[11px]`/`[15px]`/`[13px]`), `aria-label` sur le `<a>` conservé tel quel (déjà correct).

**Trois points à cadrer avec le client en amont** : (a) SVG en `viewBox="0 0 24 24"`, pas de `width`/`height` en dur ; (b) **une seule couleur**, en `fill="currentColor"` — sinon le `hover:text-foreground` du bouton ne fonctionnera pas ; (c) passer chaque fichier par SVGO (le skill `react-best-practices` §6.4 *Optimize SVG Precision* : réduire la précision décimale des `path` divise souvent leur poids par deux).

---

## 7. Formulaire : shadcn `Field`/`InputGroup` — recommandation nuancée

`shadcn/rules/forms.md` prescrit `FieldGroup`+`Field` et, pour un bouton dans un input, `InputGroup`+`InputGroupAddon`. Aucun n'est installé (`src/components/ui/` = `breadcrumb`, `button`, `select` + composants maison).

**Ce que je recommande — et ce que je ne recommande pas :**

- ❌ **Ne pas installer `input-group`.** La maquette n'a *pas* un bouton *dans* l'input : c'est un input à bordure basse (`border-0 border-b`) **à côté** d'un bouton plein, qui passe en pleine largeur sous l'input en mobile (`flex-col` → `sm:flex-row`). `InputGroup` produit un conteneur bordé unifié — l'inverse exact du style « machined » de `docs/brand.md` (« Simple 1px bottom border (#8E8E93) »). L'adopter demanderait de surcharger tout le composant, ce que `shadcn/rules/styling.md` interdit explicitement (« `className` for layout only »).
- ✅ **Installer `field`** (`bunx shadcn@latest add field`) : `Field` + `FieldLabel` + `FieldError` apportent exactement ce qui manque au formulaire — le câblage `htmlFor`/`id`/`aria-describedby`/`aria-invalid` (#1, #5) *gratuitement et correctement*. `FieldLabel className="sr-only"` est un cas explicitement documenté dans `forms.md`.
- ✅ **Installer `input`** et lui appliquer le style machined **une fois** via une variante, plutôt que de recopier la chaîne de 12 utilitaires de `NewsletterForm.tsx:25` — d'autant qu'elle est **déjà dupliquée** dans `ContactForm.tsx:20` avec des tokens différents (`border-brushed-steel` vs `border-on-inverse-surface/40`). Deux formulaires, deux définitions du même champ : c'est la dette à solder.
- ✅ **Utiliser le `Button` existant** (`src/components/ui/button.tsx` est déjà là) au lieu du `<button>` stylé à la main de `NewsletterForm.tsx:30`. À vérifier : que ses variantes couvrent le cas « fond clair sur footer sombre » ; sinon ajouter une variante `inverse` au `cva`, **pas** une surcharge `className`.
- ⚠️ **Séquencement** : cette refonte touche `NewsletterForm` **et** `ContactForm` simultanément (même pattern, mêmes défauts). À faire en un seul lot, après les correctifs a11y unitaires — sinon on corrige deux fois.

---

## 8. Top 5 des actions

1. **Corriger l'`id` dupliqué** (#1) — `useId()` dans `NewsletterForm.tsx:20`. HTML actuellement invalide sur `/blog`, et le `<label>` du bandeau pointe vers l'input du footer. Bug fonctionnel avéré, correctif d'une ligne. Enchaîner sur #18 (décider quel formulaire garder sur `/blog`).
2. **Lot a11y du formulaire** (#4, #5, #20, #22, #2) — conteneur `role="status" aria-live="polite"` permanent, `required`/`autoComplete="email"`/`inputMode`/`aria-invalid`/`aria-describedby`, suppression de l'`aria-label` redondant, `focus-visible` explicite, et `hover:text-white` → `hover:text-foreground` (2.46:1 → 6.98:1). Cinq correctifs, un seul fichier, ~15 lignes. Appliquer les mêmes à `ContactForm.tsx` dans la foulée.
3. **Durcir la Server Action** (#9, #25, #10) — `z.email().max(254).toLowerCase()`, honeypot, rate-limit par IP, et **décider** : soit on branche l'ESP, soit on arrête de répondre « Thank you for subscribing » à un email qu'on jette. Aujourd'hui c'est un POST public illimité qui ment à l'utilisateur, sans consentement ni lien vers une politique de confidentialité. Classé CRITICAL par `server-auth-actions`.
4. **Réparer le maillage interne et l'année** (#11, #12, #13, #6) — aligner les 6 liens du footer sur les 8 du header avec les **libellés du header**, ajouter `aria-label` au `<nav>`, supprimer l'année figée au build. Le footer est le seul bloc de liens présent sur 100 % des pages : trois pages du sitemap (`/design-development`, `/gallery`, `/news-events`) n'y reçoivent aucun lien. Effort S, impact SEO structurel.
5. **Extraire `<Wordmark>` et les icônes SVG** (#14, #15, #16, #3, #21) — un composant `Wordmark` avec prop `tone`, trois composants SVG locaux `currentColor` pour les réseaux (**pas** lucide : les icônes n'existent pas), `rounded-full` supprimé, `/45` et `/35` remontés au-dessus des seuils de contraste. Solde simultanément une duplication de code, une duplication i18n, la seule entorse au *Sharp 0px* du design system et deux échecs WCAG.

**Hors top 5 mais à planifier** : #17 (échelle typographique dans `@theme` — 26 valeurs arbitraires ici, 25+ tailles distinctes et 35× `max-w-[1440px]` dans tout `src/`) et #8 (scoper `NextIntlClientProvider` — ~50 Ko de JSON dans le payload RSC de chaque page). Effort L tous les deux, mais ce sont les deux seuls chantiers dont le bénéfice dépasse largement le footer.
