# Audit — `ValuePropSection` / `MaterialsSection` / `Overline` / `Reveal`

Branche `feature/home-best-practices-audit`. Lecture seule, aucun fichier modifié. Toutes les valeurs de contraste ci-dessous sont calculées selon la formule WCAG 2.x (compositing alpha inclus), pas estimées à l'œil.

---

## 1. Tableau des constats

| # | Sévérité | Axe | Fichier:ligne | Constat | Correction (avant → après) | Effort | Quick win |
|---|---|---|---|---|---|---|---|
| 1 | **Bloquant** | 3 | `src/components/motion/Reveal.tsx:39` | Le failsafe `setTimeout(2500)` est **inconditionnel** : à t=2,5 s les **25** `<Reveal>` de la Home passent `shown=true`, y compris ceux à 6 écrans plus bas. Un visiteur qui scrolle après 2,5 s (donc tout le monde) ne voit **jamais** l'animation de ValueProp / Materials / Process / Gallery. On paie 25 IntersectionObservers + 25 timers + 25 re-renders simultanés pour un effet qui ne se déclenche pas. | `const failSafe = window.setTimeout(…, 2500)` → ne l'armer que si l'API manque : `if (!("IntersectionObserver" in window)) { setShown(true); return; }` — ou supprimer l'observer au profit du CSS (cf. §3) | S | ✅ |
| 2 | **Majeur** | 3 | `src/components/ui/Overline.tsx:35` | `text-gold` (`#a6863f`) à **11 px** : **3,26:1** sur `#f9f9f7`, **3,44:1** sur `#ffffff`. WCAG 2.2 AA (1.4.3) exige 4,5:1 pour du texte < 18,66 px. Échec. Le composant est utilisé dans **~20 sections** du site → défaut systémique, pas local. | Assombrir le token : `--color-gold: #a6863f` → `#8a6f34` (**4,77:1** sur `#fff`, **4,52:1** sur `#f9f9f7`, même teinte). Aucune classe à toucher. | S | ✅ |
| 3 | **Majeur** | 3 | `ValuePropSection.tsx:30` | Numéral `01`–`04` en `text-burnished-gold` (`#c5a059`) sur `bg-background` (`#f9f9f7`) = **2,33:1** à 15 px. Échec 1.4.3, de loin. | Soit `text-gold` corrigé (#8a6f34), soit `--color-burnished-gold` réservé aux filets et un `--color-gold-text` pour les numéraux. `#8e6e32` atteint 4,5:1 dans la même teinte. | S | ✅ |
| 4 | **Majeur** | 3/5 | `MaterialsSection.tsx:16` + `messages/{en,fr}.json → IndexPage.materials.imageAlt` | **L'alt décrit une autre image.** Vérification visuelle : l'image montre une technicienne de dos ouvrant le capot d'un **spectromètre XRF de laboratoire**, écran affichant « Product No 7 AgCuZn925 » et une radiographie, étiquette de mise en garde rayonnement. **Aucun or brut ni métal précieux dans le cadre.** L'alt annonce « Raw gold and precious metals prepared for jewelry production » / « Or brut et métaux précieux… ». | L'image est en outre **purement décorative** (masquée par un overlay 0,72–0,82). Précédent maison : `StatsSection.tsx:25` utilise déjà `alt="" aria-hidden="true"`. → `alt="" aria-hidden="true"` + suppression de la clé `imageAlt` (EN+FR, utilisée nulle part ailleurs). Si on veut garder un alt informatif : EN « Quality control technician running an XRF assay on a 925 silver alloy sample in the Advance lab » / FR « Technicienne du contrôle qualité analysant un échantillon d'alliage argent 925 au spectromètre XRF au laboratoire ». | S | ✅ |
| 5 | **Majeur** | 2 | `ValuePropSection.tsx:29` | `motion-safe:hover:-translate-y-1` + `hover:shadow-[0_18px_40px_-22px_rgba(26,28,27,0.35)]` : **contredit frontalement** `docs/brand.md:148` (« avoids traditional drop shadows ») et `:150` (« Cards do not "float"; they are defined by their content and subtle hairline borders »). Seule ombre portée du périmètre. | Supprimer les deux. Hover conforme au tonal layering : `hover:bg-surface-container-low hover:border-outline-strong` (transition `border-color`/`background-color` uniquement). | S | ✅ |
| 6 | **Majeur** | 2 | `ValuePropSection.tsx:26` `MaterialsSection.tsx:39` | `grid-cols-[repeat(auto-fit,minmax(240px,1fr))]` (resp. `230px`). Calcul exact avec `max-w-1440 px-6 lg:px-16` + `gap-4 lg:gap-6` : nombre de colonnes = `floor((W+gap)/(min+gap))`. **Entre 1024 px et 1160 px de viewport → 3 colonnes + 1 orpheline** (ValueProp) ; **1024–1120 px** pour Materials. Le passage 1→2 colonnes tombe à ~544 px, valeur non déclarée et non testable. Non mobile-first (cf. `.claude/skills/tailwind-css-patterns/references/layout-patterns.md:63`). | `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` — 1/2/4, jamais d'orpheline, breakpoints explicites. | S | ✅ |
| 7 | Moyen | 3 | `ValuePropSection.tsx:30` | Le numéral décoratif `01` n'est pas `aria-hidden`. NVDA/VoiceOver annoncent « zéro un » avant chaque titre de carte. Aucune sémantique d'ordre ici (« Why Brands Choose Advance » n'est pas une séquence). `StatsSection.tsx:25` et `MaterialsSection.tsx:43` masquent bien leurs décorations. | Ajouter `aria-hidden="true"` sur le `<span>`. | S | ✅ |
| 8 | Moyen | 4 | `MaterialsSection.tsx:33` | **Seule section centrée de la Home sans `<Overline>`** : ValueProp:20, Process:30, Gallery:51 en ont un ; `IndexPage.materials` n'a même pas de clé `overline`. Rupture de rythme visuel au milieu de la page. | Ajouter `materials.overline` (EN/FR) + `<Overline className="mb-4">` au-dessus du h2 — ou documenter l'exception comme le fait `AboutSection.tsx:16`. | S | ✅ |
| 9 | Moyen | 4 | `ValuePropSection.tsx:26-40` vs `MaterialsSection.tsx:39-53` | **Deux grilles de 4 cartes au squelette identique** : `<Reveal delay={90*i}>` → `div` bordé `text-center` → marqueur or (numéral / filet) → `h3 font-display` → `p`. Seuls le thème (clair/inversé) et le marqueur diffèrent. | Extraire `src/components/ui/FeatureCard.tsx` (`tone: "surface" | "inverse"`, `marker: ReactNode`, `title`, `children`) + `FeatureGrid`. Composant **serveur** — pas besoin de shadcn `Card` ici (non installé : `src/components/ui/` ne contient que `And/Overline/PageSkeleton/breadcrumb/button/select`, et `Card` shadcn apporte `rounded-xl`+`shadow-sm`, tous deux contraires à `brand.md` « Sharp 0px » / « no drop shadows »). | M | — |
| 10 | Moyen | 1 | `ValuePropSection.tsx:28` `MaterialsSection.tsx:41` | 10 instances de `<Reveal>` sur ces deux sections (25 sur la Home) = 10 IntersectionObservers + 10 `useState`/`useEffect`/`setTimeout` pour un effet purement visuel. **Nuance factuelle** : les cartes elles-mêmes restent server-rendered (passées en `children`) — le coût est en runtime/hydratation, pas en bundle. | Cf. §3 : soit un observer unique (`RevealRoot`), soit reveal CSS pur. | M | — |
| 11 | Moyen | 3/2 | `MaterialsSection.tsx:47` | `text-white/[.78]` à 14 px sur `bg-white/[.06]` + dégradé 0,72–0,82 sur **photo claire** (mur blanc). Pire cas calculé : **4,56:1** — passe AA avec 0,06 de marge, et **le ratio dépend du recadrage `object-cover`, donc du viewport** : non déterministe, non testable. | `text-white/[.78]` → `text-on-inverse-surface` (`#f1f1ef`, **5,52:1** dans le même pire cas) — et au passage on sort d'une couleur brute vers un token (`.claude/skills/shadcn/rules/styling.md`, « Semantic colors »). | S | ✅ |
| 12 | Moyen | 2 | `MaterialsSection.tsx:26-28` | Dégradé en `style={{ background: "linear-gradient(...)" }}` : hors pipeline Tailwind (pas de purge, pas de token, `#141514` n'existe dans aucun `@theme`, le plus proche étant `--color-on-surface: #1a1c1b`). | Ajouter `--color-scrim: #141514` puis `className="absolute inset-0 bg-linear-to-b from-scrim/72 to-scrim/82"` (syntaxe v4). | S | ✅ |
| 13 | Moyen | 2 | `MaterialsSection.tsx:33` vs `ValuePropSection.tsx:21` | **Deux h2 de même niveau, deux échelles typographiques différentes** : `lg:text-[40px] lg:leading-[1.15]` d'un côté, `lg:text-[clamp(34px,4.5vw,52px)] lg:leading-[1.1]` de l'autre. Même incohérence sur le rythme vertical : `lg:py-[104px]` vs `lg:py-[110px]` sur deux sections adjacentes. | Un seul token `--text-h2` / `--text-h2-lg` et un seul `--spacing-section-y-lg`. | S | ✅ |
| 14 | Moyen | 2 | `globals.css` `@theme` (spacing) | `--spacing-section: 96px`, `--spacing-section-sm: 64px`, `--spacing-margin-mobile: 16px`, `--spacing-margin-desktop: 64px` sont déclarés et **utilisés 0 fois** (seul `gap-x-gutter` sort une occurrence). Pendant ce temps `py-[76px]` apparaît **28×** et `py-[104px]` **17×** en dur. Les tokens sont morts et faux. | Remplacer par `--spacing-section-y: 76px` / `--spacing-section-y-lg: 104px` (les vraies constantes du design) → `py-section-y lg:py-section-y-lg`, et supprimer les tokens morts (knip). | M | — |
| 15 | Moyen | 2 | `ValuePropSection.tsx:18` `MaterialsSection.tsx:31` | `max-w-[1440px]` répété **35 fois dans 33 fichiers**. Aucun token conteneur. (Et `globals.css` définit un `@utility container` à `50rem` — reliquat shadcn qui n'a rien à voir avec la grille éditoriale.) | `@theme { --container-editorial: 1440px }` → `max-w-editorial`, ou une `@utility container-editorial` qui embarque aussi `mx-auto px-6 lg:px-16` (9 répétitions). | M | — |
| 16 | Mineur | 4 | `src/components/ui/And.tsx` (fichier entier) | **Code mort intégral** : `And`, `andTags`, `splitAmpersand` ne sont importés nulle part (0 occurrence hors du fichier), et aucun message ne contient `<and>`. Or `MaterialsSection.tsx:33` affiche « Materials & Standards » / « Matériaux & standards » avec un `&` littéral — la convention documentée dans le fichier lui-même n'est pas appliquée. | Trancher : soit appliquer (`splitAmpersand(t("title"))` — mais le `&` Playfair dans un titre Tenor Sans est douteux vu l'exception éditoriale Home), soit **supprimer `And.tsx`** au prochain passage knip. Ne pas laisser les deux. | S | ✅ |
| 17 | Mineur | 4 | `MaterialsSection.tsx:43` vs `Overline.tsx:4` | Filet or ad hoc `h-px w-11 bg-burnished-gold` vs la règle canonique `RULE = "…h-px w-[26px]…sm:w-12"`. Même couleur, largeurs différentes (44 px vs 26/48 px), aucune source commune. | Exporter la constante `RULE` (ou un `<GoldRule />`) depuis `Overline.tsx` et la consommer ici. | S | ✅ |
| 18 | Mineur | 2 | `ValuePropSection.tsx:29` `MaterialsSection.tsx:42` | `text-center` posé **sur chaque carte** (commits `08957cc`, `15b8543`). Il est bien hérité proprement par le `span`/`h3`/`p` — pas de bug — mais il est répété 4× par section alors qu'il pourrait vivre une seule fois sur le conteneur de grille. Idem `<Reveal className="mb-16 text-center">` L19 : `Overline` force déjà `text-center` en interne (L35). | Déplacer `text-center` sur le `div.grid`. | S | ✅ |
| 19 | Mineur | 3 | `MaterialsSection.tsx:20` | `sizes="100vw"` sur une source de **800×618**. En import statique le srcSet plafonne à `800w` → **pas d'octets gaspillés** (30 Ko, `loading="lazy"` correct pour la 6ᵉ section). Le vrai problème est l'inverse : **upscale ~1,8×** sur un écran 1440 px. Invisible ici (overlay à 72–82 %), mais le fichier est sous-dimensionné pour un fond plein écran. | Soit ré-exporter la source à ~1920 px, soit assumer (fond flouté) et le documenter. | S | — |
| 20 | Mineur | 1 | `MaterialsSection.tsx:19` | `placeholder="blur"` sur une image intégralement recouverte par un overlay opaque à 72–82 % : le `blurDataURL` base64 est inliné dans le HTML de la Home à chaque rendu pour un effet **invisible**. | Supprimer `placeholder="blur"`. Bonus déjà correct : la section n'ayant pas de `bg-*`, l'overlay sur `#f9f9f7` donne ~`#565656` avant chargement → pas de flash texte blanc sur blanc. | S | ✅ |
| 21 | Info | 3 | `MaterialsSection.tsx:42` | Bordure `border-white/[.16]` vs fond de carte `bg-white/[.06]` = **1,32:1**. Sous les 3:1 de 1.4.11 — mais la carte n'étant ni interactive ni porteuse d'information, le critère ne s'applique pas strictement. Signalé pour information : la délimitation des cartes est quasi invisible sur les zones claires de la photo. | Monter à `border-white/25` si on veut la hairline lisible. | S | — |
| 22 | Info | 3 | Home entière | **Hiérarchie de titres valide, aucun saut de niveau.** h1 unique (`HeroHome.tsx:41`) → 5 h2 (`CertificationSection:19`, `ValueProp:21`, `Materials:33`, `Process:31`, `Gallery:52`) → **14 h3** (ValueProp 4, Materials 4, Process 6) — et non 8. `StatsSection` et `AboutSection` n'ont volontairement pas de h2 (commenté en clair, `AboutSection.tsx:16`), ce qui est acceptable. Aucune action. | — | — | — |
| 23 | Info | 2 | `MaterialsSection.tsx:31` | `z-[2]` arbitraire pour un simple empilement image / overlay / contenu. | `z-10` (échelle standard). | S | ✅ |

---

## 2. Inventaire exhaustif des valeurs arbitraires → remplacement

Prérequis : ajouter dans `@theme` de `globals.css` les tokens `--spacing-section-y: 76px`, `--spacing-section-y-lg: 104px`, `--container-editorial: 1440px`, `--container-lead: 720px`, `--text-h2: 31px`, `--text-h2-lg: 40px`, `--text-card-title: 21px`, `--text-lead: 17px`, `--text-overline: 11px`, `--leading-body: 1.65`, `--tracking-overline: 0.26em`, `--color-scrim: #141514`.

### `ValuePropSection.tsx`

| Ligne | Valeur arbitraire | Remplacement |
|---|---|---|
| 17 | `py-[76px]` | `py-section-y` |
| 17 | `lg:py-[104px]` | `lg:py-section-y-lg` |
| 18 | `max-w-[1440px]` | `max-w-editorial` (ou `@utility container-editorial` incluant `mx-auto px-6 lg:px-16`) |
| 21 | `text-[31px]` | `text-h2` |
| 21 | `leading-[1.18]` | porté par `--text-h2--line-height: 1.18` |
| 21 | `lg:text-[40px]` | `lg:text-h2-lg` |
| 21 | `lg:leading-[1.15]` | porté par `--text-h2-lg--line-height` |
| 26 | `grid-cols-[repeat(auto-fit,minmax(240px,1fr))]` | `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` |
| 29 | `p-[32px_26px]` | `px-6 py-8` (24/32 — l'écart de 2 px sur le padding horizontal n'est pas perceptible) |
| 29 | `lg:p-[38px_32px]` | `lg:px-8 lg:py-9` (32/36) |
| 29 | `hover:shadow-[0_18px_40px_-22px_rgba(26,28,27,0.35)]` | **supprimer** (`brand.md:148`) |
| 29 | `motion-safe:hover:-translate-y-1` | **supprimer** (`brand.md:150`, « cards do not float ») → `hover:bg-surface-container-low` |
| 30 | `text-[15px]` | `text-sm` (ou `--text-numeral: 15px`) |
| 31 | `mt-[18px]` | `mt-4` (16 px) |
| 31 | `mb-[14px]` | `mb-3.5` (14 px, exact) |
| 31 | `text-[21px]` | `text-card-title` |
| 31 | `leading-[1.3]` | porté par `--text-card-title--line-height: 1.3` |
| 34 | `text-[14.5px]` | `text-sm` — **le demi-pixel s'arrondit différemment selon le moteur de rendu**, à bannir |
| 34 | `leading-[1.65]` | `leading-body` |

### `MaterialsSection.tsx`

| Ligne | Valeur arbitraire | Remplacement |
|---|---|---|
| 26-28 | `style={{ background: "linear-gradient(180deg, rgba(20,21,20,0.72), rgba(20,21,20,0.82))" }}` | `className="… bg-linear-to-b from-scrim/72 to-scrim/82"` |
| 31 | `z-[2]` | `z-10` |
| 31 | `max-w-[1440px]` | `max-w-editorial` |
| 31 | `py-[76px]` | `py-section-y` |
| 31 | `lg:py-[110px]` | `lg:py-section-y-lg` (**et aligner sur 104 px**, cf. constat 13) |
| 32 | `max-w-[720px]` | `max-w-lead` |
| 33 | `text-[31px]` / `leading-[1.18]` | `text-h2` |
| 33 | `lg:text-[clamp(34px,4.5vw,52px)]` / `lg:leading-[1.1]` | `lg:text-h2-lg` (**unifier avec ValueProp**) |
| 36 | `text-[17px]` | `text-lead` |
| 36 | `leading-[1.7]` | `leading-body` (1,65 — écart non perceptible) ou `--leading-lead: 1.7` |
| 36 | `text-white/[.82]` | `text-white/82` (v4 accepte les modificateurs d'opacité arbitraires sans crochets) → mieux : `text-on-inverse-surface` |
| 39 | `grid-cols-[repeat(auto-fit,minmax(230px,1fr))]` | `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` |
| 42 | `border-white/[.16]` | `border-white/16` (ou `/25`, cf. constat 21) |
| 42 | `bg-white/[.06]` | `bg-white/6` |
| 42 | `p-[34px_30px]` | `px-8 py-8` → `p-8` |
| 42 | `backdrop-blur-[8px]` | `backdrop-blur-sm` (= 8 px en Tailwind v4) |
| 43 | `mb-[22px]` | `mb-5` (20 px) |
| 43 | `w-11` | constante `RULE` partagée avec `Overline` (`w-6.5 sm:w-12`) |
| 44 | `mb-[14px]` | `mb-3.5` |
| 44 | `text-[22px]` | `text-card-title` (**unifier avec les 21 px de ValueProp**) |
| 47 | `text-[14px]` | `text-sm` |
| 47 | `leading-[1.65]` | `leading-body` |
| 47 | `text-white/[.78]` | `text-on-inverse-surface` (contraste, cf. constat 11) |

### `Overline.tsx`

| Ligne | Valeur arbitraire | Remplacement |
|---|---|---|
| 4 | `w-[26px]` | `w-6.5` (v4 accepte les pas fractionnaires) |
| 35 | `text-[11px]` | `text-overline` — **36 occurrences de `text-[11px]` dans le projet** |
| 35 | `tracking-[0.26em]` | `tracking-overline` — attention : `ProcessSection.tsx:49` et `StatsSection.tsx:38/59/75` utilisent `tracking-[0.2em]` pour le **même** style de label. Deux valeurs pour un seul rôle → un seul token |
| 35 | `text-gold` | token à corriger : `#a6863f` → `#8a6f34` (constat 2) |

### `Reveal.tsx`

| Ligne | Valeur | Remplacement |
|---|---|---|
| 50 | `style={{ transitionDelay }}` inline | `style={{ "--reveal-delay": … }}` + `transition-delay: var(--reveal-delay, 0ms)` dans `.reveal`, ou stagger par `nth-child` en CSS (cf. §3) |

---

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

### Actuel

```
IndexPage (server)
├─ ValuePropSection      SERVER  ✅ pas de 'use client'
│  └─ Reveal × 5         CLIENT  (1 titre + 4 cartes avec delay 0/90/180/270)
│     └─ children        SERVER  ✅ le markup des cartes reste RSC, passé en `children`
└─ MaterialsSection      SERVER  ✅ pas de 'use client'
   └─ Reveal × 5         CLIENT
      └─ children        SERVER  ✅
```

Ce qui est **déjà bon** : les deux sections sont des Server Components, `useTranslations` (hook synchrone next-intl) fonctionne côté serveur, et le pattern « children as slot » (`.claude/skills/nextjs-performance/references/server-components.md:66`) est correctement appliqué — le contenu des cartes n'entre **pas** dans le bundle client. Le coût n'est donc pas du JS de rendu mais du runtime d'hydratation.

Ce qui ne l'est pas : **10 instances client dans le périmètre, 25 sur la Home entière** (About 2, Cert 2, Gallery 2, Materials 5, Process 7, Stats 2, ValueProp 5) → 25 `IntersectionObserver`, 25 `window.setTimeout`, 25 paires `useState`/`useEffect`. Et à cause du constat #1, **cette machinerie ne produit aucun effet visible en dessous de la ligne de flottaison**.

### Cible A — un observer unique (compatible tous navigateurs, effort S/M)

Un seul composant client `RevealRoot` monté une fois dans `[locale]/layout.tsx` ; les sections restent 100 % serveur et n'émettent que du markup statique :

```tsx
// serveur, aucune frontière client
<div data-reveal style={{ "--reveal-delay": `${90 * index}ms` } as CSSProperties}>
```

`RevealRoot` fait `document.querySelectorAll("[data-reveal]")`, les observe avec **un** `IntersectionObserver` partagé, et ajoute `.reveal-in` puis `unobserve`. Bilan : 25 composants → 1, 25 observers → 1, 0 `useState`. Le failsafe devient un simple garde `if (!("IntersectionObserver" in window))`.

### Cible B — reveal CSS pur, 0 JS (recommandée)

`animation-timeline: view()` en **enrichissement progressif**, contenu visible par défaut :

```css
@keyframes reveal-in { from { opacity: 0; transform: translateY(24px) } to { opacity: 1; transform: none } }

/* .reveal n'est PAS masqué par défaut : pas de failsafe nécessaire, pas de FOUC, pas de contenu piégé */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .reveal { animation: reveal-in linear both; animation-timeline: view(); animation-range: entry 10% cover 32% }
    /* stagger : avec une timeline de scroll, `animation-delay` s'exprime en unités de
       progression, PAS en ms — le décalage se fait donc sur animation-range */
    .reveal-grid > :nth-child(2) { animation-range: entry 16% cover 38% }
    .reveal-grid > :nth-child(3) { animation-range: entry 22% cover 44% }
    .reveal-grid > :nth-child(4) { animation-range: entry 28% cover 50% }
  }
}
```

Deux précisions qui comptent :

- **`@starting-style` n'est pas le bon outil ici.** Il ne se déclenche qu'au premier rendu / à l'apparition d'un élément, pas en fonction de la position de scroll. Il pourrait animer le hero at-fold, pas les sections basses.
- **Support de `animation-timeline: view()`** : Chrome/Edge 115+, Safari 26+, **Firefox non livré par défaut**. D'où le `@supports` avec état par défaut *visible* : les utilisateurs Firefox n'ont simplement pas d'animation — ce qui est **déjà le comportement réel aujourd'hui** à cause du failsafe de 2,5 s. On ne perd donc rien et on supprime 25 composants client.

Le composant `Reveal.tsx` disparaît, `globals.css` porte tout, et le bloc `@media (prefers-reduced-motion: reduce)` existant (déjà présent, correct) n'a même plus besoin d'inverser quoi que ce soit.

---

## 4. Inventaire images / alt du périmètre

| Composant:ligne | Fichier | Dimensions | Poids | Chargement | `sizes` | Rôle | alt EN (clé) | alt FR | Verdict |
|---|---|---|---|---|---|---|---|---|---|
| `MaterialsSection.tsx:15-22` | `public/production/metal-xray-clean.webp` | 800 × 618 | 30 308 o | `fill`, lazy (pas de `priority`) ✅ — 6ᵉ section, correct | `100vw` (srcSet plafonné à `800w` par l'import statique → 0 octet gaspillé, mais upscale ~1,8× à 1440 px) | **Décorative** (masquée par un overlay 0,72–0,82) | `IndexPage.materials.imageAlt` : « Raw gold and precious metals prepared for jewelry production » | « Or brut et métaux précieux préparés pour la fabrication de bijoux » | ❌ **alt factuellement faux** (l'image montre un spectromètre XRF en labo QC, échantillon « AgCuZn925 », zéro or brut) **+ rôle décoratif non déclaré** → `alt="" aria-hidden="true"` et suppression de la clé |

`ValuePropSection.tsx` et `Overline.tsx` ne contiennent aucune image. Les deux marqueurs décoratifs non textuels du périmètre (`MaterialsSection.tsx:43` filet or, `Overline.tsx:40/44` filets) sont correctement `aria-hidden="true"` ✅.

---

## 5. Top 5 des actions

1. **Corriger le failsafe de `Reveal.tsx:39`** (2 lignes) — c'est le seul constat *bloquant* : aujourd'hui l'animation de scroll ne se déclenche sur aucune des sections basses de la Home, et on paie 25 observers pour rien. Enchaîner ensuite sur la cible B (§3) pour supprimer 25 composants client d'un coup.
2. **Assombrir `--color-gold` en `#8a6f34`** (1 ligne dans `globals.css`) — fait passer `<Overline>` de 3,26:1 à 4,52:1 et met **~20 sections du site** en conformité WCAG 2.2 AA 1.4.3. Traiter dans la foulée le numéral `text-burnished-gold` de `ValuePropSection.tsx:30` (2,33:1, le pire ratio du périmètre) et lui ajouter `aria-hidden="true"`.
3. **Réparer `IndexPage.materials.imageAlt`** — un alt qui décrit une image inexistante est le pire cas possible pour un lecteur d'écran. Passer en `alt="" aria-hidden="true"` (aligné sur `StatsSection.tsx:25`) et supprimer la clé EN/FR. Retirer aussi `placeholder="blur"`, invisible sous l'overlay.
4. **Remplacer les deux `auto-fit minmax` par `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4`** et **supprimer l'ombre + le `-translate-y-1`** de `ValuePropSection.tsx:29`. Ça règle d'un coup la carte orpheline entre 1024 et 1160 px et la seule violation frontale de `docs/brand.md` du périmètre.
5. **Poser les tokens `@theme` manquants et extraire `FeatureCard`** — c'est le chantier de fond : `py-[76px]` × 28, `max-w-[1440px]` × 35, `text-[11px]` × 36, deux échelles h2 concurrentes, quatre tokens `--spacing-*` morts. Extraire `FeatureCard`/`FeatureGrid` (composant serveur, pas `Card` shadcn qui apporte `rounded-xl` + `shadow-sm`, tous deux contraires à la charte) supprime la duplication et donne un seul endroit où appliquer les corrections 2 à 4. Passer `knip` dans le même mouvement pour évacuer `src/components/ui/And.tsx`, importé nulle part.
