# Audit Home — `HeroHome` / `StatsSection` / `Reveal` / `CountUp` / `Overline` / `button.tsx`

Branche `feature/home-best-practices-audit`. Lecture seule, aucune modification effectuée. Tous les constats sont référencés `fichier:ligne` et les mesures (dimensions, luminance, ratios de contraste) ont été calculées, pas estimées.

---

## 1. Tableau des constats

| # | Sévérité | Axe | Fichier:ligne | Constat | Correction proposée | Effort | QW |
|---|---|---|---|---|---|---|---|
| 1 | **bloquant** | 3 | `HeroHome.tsx:27-31` | Le badge « Est. 1989 · Asia » est en `text-white/90` avec filet `bg-white/70`, posé en `bottom-9 left-10` sur l'image hero. J'ai mesuré la luminance moyenne du quart bas-gauche de `white-gold-set-advance-manufacturer.webp` : **253/255** (blanc pur). Contraste réel ≈ **1,05:1**. Le badge est littéralement invisible. | Ajouter un scrim local : `<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-foreground/60 to-transparent" aria-hidden />` sous le badge, ou basculer le badge en `text-foreground`/`bg-foreground/70` puisque le fond est blanc. Le scrim est préférable (indépendant de l'asset). | S | ✅ |
| 2 | **bloquant** | 3/5 | `HeroHome.tsx:17-25` | `object-cover` sans `object-position` sur un packshot 3:2 dont le sujet occupe toute la largeur (chaîne à x≈0,02 ; créole à x≈0,90). Cadres réels : desktop lg ≈ 720×640 (ratio 1,125) → **25 % de largeur perdue** ; mobile 100vw × 46vh ≈ 390×388 (ratio 1,0) → il ne reste que **33 % de la source** (x 0,333→0,667), soit la bague et une moitié de pendentif. Les deux boucles d'oreille et la chaîne disparaissent. L'élément LCP ne montre pas ce que le `alt` promet. | Produire un asset recadré portrait/carré dédié au hero (ou 2 assets art-directed via `<picture>`/media queries), ou a minima `object-[35%_center]` + `min-h-[56vh]` mobile. Ne pas passer en `object-contain` : `bg-surface-dim` (#dadad8) créerait des bandes grises autour d'un packshot blanc. | M | |
| 3 | **majeur** | 3 | `Overline.tsx:45`, `StatsSection.tsx:40,68,86` | `text-gold` = `#a6863f`. Sur `--color-surface-container-low` (#f4f4f2) : **3,12:1**. Sur `--background` (#f9f9f7) : **3,26:1**. Le texte fait 11 px (donc « normal text », pas « large ») → WCAG 2.2 AA exige 4,5:1. **Échec sur toute la Home**, pas seulement ces deux sections. | Assombrir le token à teinte constante : `--color-gold: #856b33` → 4,60:1 sur surface-container-low, 4,80 sur background, 5,06 sur blanc. Conserver `--color-gold-bright` pour les fonds sombres et `--color-burnished-gold` (#c5a059) pour les filets décoratifs (non-texte, non concerné). | S | ✅ |
| 4 | **majeur** | 3 | `HeroHome.tsx:53` | CTA primaire : `hover:bg-gold` avec `text-background` (#f9f9f7) → **3,26:1** au survol. Le bouton passe donc sous AA dans un de ses états. Même motif dans `DesignCta.tsx:20` et `GalleryCta.tsx:20`. | `hover:bg-gold hover:text-foreground` : `#1a1c1b` sur `#a6863f` = **4,98:1** ✅. L'intention design (hover doré) est préservée sans changer le token. | S | ✅ |
| 5 | **majeur** | 2/4 | `ui/button.tsx` (fichier entier) | `grep -rn "ui/button" src` → **0 résultat**. Le composant shadcn `Button` n'est importé nulle part. Pendant ce temps 4 CTA sont restylés à la main (`HeroHome.tsx:53,59`, `DesignCta.tsx:20`, `GalleryCta.tsx:20`) avec la même chaîne de 12 classes. Le `button.tsx` actuel est par ailleurs inutilisable tel quel : `rounded-lg` alors que `docs/brand.md` impose « Sharp (0px) », `h-8`/`h-9` (32/36 px, < 44 px de cible tactile), `text-sm` lowercase. | Ajouter deux variants brand au `cva` et supprimer les 4 duplications : `cta: "h-auto rounded-none bg-foreground px-6 py-4.5 text-label uppercase text-background hover:bg-gold hover:text-foreground lg:px-8"` et `cta-outline: "h-auto rounded-none border border-foreground px-6 py-4.5 text-label uppercase text-foreground hover:bg-foreground hover:text-background lg:px-8"`. Rendu via la prop `render` de base-ui : `<Button variant="cta" render={<Link href="/production-process" />}>`. **Vérifier avant** que `@base-ui/react/button` accepte bien `render` sur un `next-intl` `Link` (pattern `render` ≠ `asChild`). | M | |
| 6 | **majeur** | 1 | `Reveal.tsx:22-42` + 25 instances Home | Chaque `<Reveal>` instancie **son propre `IntersectionObserver`** + un `setTimeout`. Décompte runtime de la Home : About 2, Certification 2, ValueProp 1+4, Materials 1+4, Process 1+6, Gallery 2, Stats 2 = **25 observers**, + 3 pour `CountUp` = **28 `IntersectionObserver` sur une page unique**. Repo-wide : 65 usages statiques. | Deux options, par ordre de préférence : **(a) CSS pur, 0 JS** — `animation-timeline: view()` + `animation-range: entry 0% cover 30%` sur `.reveal` dans `globals.css`, gardé derrière `@supports (animation-timeline: view())` avec le fallback actuel. Disponible Chrome 115+, Edge, Firefox 144+, Safari 26+. Supprime le composant client de l'arbre. **(b)** Si le support baseline est jugé insuffisant : un observer **module-singleton** partagé (`const io = new IntersectionObserver(...)` au niveau module, `Map<Element, () => void>`) — c'est exactement la règle 4.1 « Deduplicate Global Event Listeners » du skill `react-best-practices` (N instances → 1 listener). | M (a) / M (b) | |
| 7 | **majeur** | 1 | `CountUp.tsx:29-32` + `44-58` | `new Intl.NumberFormat(...)` est construit **à chaque render** (ligne 29, hors `useMemo`) et l'animation déclenche `setValue` **à chaque frame** pendant 1800 ms → ~108 renders React × 3 compteurs ≈ **324 renders + 324 constructions d'`Intl.NumberFormat`**, dont chacune est une opération ICU coûteuse. Sur du main-thread pendant le scroll = risque INP. | Écrire la valeur directement dans le DOM (règle 5.15 « Use `useRef` for Transient Values ») : garder le `useState` uniquement pour la valeur SSR, et dans la boucle `rAF` faire `valueRef.current.textContent = format.format(v)` — **0 re-render React**. Hisser le formateur : `const format = useMemo(() => new Intl.NumberFormat(locale, {...}), [locale, decimals])`. Encore mieux : formater côté serveur (`getFormatter()` de next-intl) et passer `formatted: string` en prop, ce qui supprime aussi `useLocale()` du client. | M | |
| 8 | **majeur** | 3 | `StatsSection.tsx:24-31` | `az4948_ggpzcg.webp` : **3000×2000, 273 Ko**, servi avec `sizes="(min-width:1024px) 50vw, 100vw"` pour un lavis à **`opacity-[.16]`**. Deux problèmes : (a) `sizes` est faux au-delà de 1440 — le conteneur est `max-w-[1440px] px-16` et la carte `lg:col-span-6`, donc ~644 px **fixes** ; à 1920 px de viewport, 50vw = 960 px → Next sert le palier 1080 au lieu de 750 ; (b) `next.config.ts:15` n'autorise que `qualities: [75]`, donc impossible de baisser la qualité d'un fond à 16 %. | `sizes="(min-width:1440px) 660px, (min-width:1024px) 50vw, 100vw"` + ajouter `40` à `qualities` dans `next.config.ts` et poser `quality={40}` sur cette image. Idéal : pré-générer un asset 900 px dédié (le détail est invisible à 16 %). | S | ✅ |
| 9 | **majeur** | 2 | `HeroHome.tsx:13` | `lg:h-[calc(640px+89px)]` = hauteur **fixe** 729 px, pas `min-h`. À 1024 px, la moitié texte fait ~512 px moins `lg:px-16` (128 px) = 384 px utiles ; le titre FR « Fabricant de bijoux fins en or & argent » à `clamp(36px,4.6vw,58px)` ≈ 47 px y prend 3-4 lignes. Overline (30) + titre (~200) + sous-titre 5 lignes (~150) + CTA (~60) + gaps dépasse les 729−128 = 601 px disponibles → débordement visuel en FR entre 1024 et ~1200 px. | `lg:min-h-[calc(...)]` au lieu de `lg:h-[...]`. Aucune régression : `justify-center` continue de centrer, et la moitié image suit par `stretch`. | S | ✅ |
| 10 | **majeur** | 2 | `HeroHome.tsx:13` vs `blog/page.tsx:66`, `PostLayout.tsx:53`, `AboutHero.tsx:10`, `DesignHero.tsx:13` | **Trois sources de vérité contradictoires** pour la hauteur du header : `89px` (HeroHome), `90px` (blog, PostLayout, AboutHero), et `var(--header-h)` publié dynamiquement par `HeaderShell.tsx:47-62`. Or `--header-h` **change au scroll** (`py-[22px]` → `lg:py-3.5`, soit −16 px) : les composants qui l'utilisent en `padding-top` (`AboutHero.tsx:10`, `DesignHero.tsx:13`) **provoquent un CLS au premier scroll**. HeroHome a raison de figer la valeur, mais pas de la coder en dur. | Deux tokens distincts dans `@theme` : `--spacing-header: 75px` et `--spacing-header-lg: 90px` (valeur *statique*, header non scrollé), à utiliser pour tout `padding-top` de page. Réserver `--header-h` (dynamique) aux éléments `sticky`/`fixed` (`BlogSubNav.tsx:552`, `ReadingProgress.tsx:36`) qui doivent effectivement suivre. Corriger `AboutHero`/`DesignHero` en conséquence. | M | |
| 11 | **majeur** | 3 | `src/config/seo/structured-data.ts:44-68` | La page affiche « Est. 1989 », « 37 ans », « 500 maîtres artisans », « 3,5 M pièces/an », mais `organizationJsonLd` ne contient **ni `foundingDate` ni `numberOfEmployees`** (grep : 0 résultat sur tout `src/`). Les chiffres les plus indexables de la Home ne sont pas structurés. | Ajouter à `organizationJsonLd` : `foundingDate: "1989"` et `numberOfEmployees: { "@type": "QuantitativeValue", value: 500 }`. **À faire valider par le client** avant publication : « 500 maîtres artisans » n'est pas nécessairement égal au nombre d'employés (schema.org attend l'effectif total). | S | ✅ |
| 12 | **majeur** | 3 | `StatsSection.tsx:11-91` | La section n'a **aucun heading ni nom accessible** : `<section>` nu (donc pas exposé comme `region`), labels en `<div>` (l.40, 68, 86), chiffres en `<span>` déconnectés de leur label. Un lecteur d'écran lit « 37 » puis « Years of Excellence » sans lien programmatique. Hiérarchie de la Home : h1 (hero) → **rien** → h2 (About). | Structurer en liste de description : `<section aria-labelledby="stats-heading">` + `<h2 id="stats-heading" className="sr-only">{t("srHeading")}</h2>` + `<dl>` / `<dt>` (label) / `<dd>` (valeur + body). Ajouter les clés `IndexPage.stats.srHeading` en EN/FR. | M | |
| 13 | **majeur** | 2/4 | `globals.css:20-24` vs repo | Les tokens d'espacement `@theme` sont **morts** : `--spacing-gutter`, `--spacing-margin-mobile`, `--spacing-margin-desktop`, `--spacing-section`, `--spacing-section-sm` → **0 usage** dans `src/`. Pendant ce temps `max-w-[1440px]` apparaît **35×** et le couple `px-6 lg:px-16` **40×**, et les paddings verticaux de section sont écrits en dur avec 5 valeurs différentes : 76/92/104/110 px (`StatsSection:11`, `AboutSection:12`, `ProcessSection:27`, `ValueProp:17`, `Gallery:48`, `Materials:31`, `Certification:12`). | (a) `@utility shell { margin-inline: auto; max-inline-size: 90rem; padding-inline: 1.5rem; @media (width >= 64rem) { padding-inline: 4rem } }` → absorbe 35+40 occurrences. (b) Recaler les tokens sur la réalité : `--spacing-section-sm: 76px` / `--spacing-section: 104px`, normaliser 92 et 110 sur 104, puis `py-section-sm lg:py-section`. Supprimer `--spacing-gutter` / `--spacing-margin-*` (knip). | M | |
| 14 | **majeur** | 2 | `globals.css:7-65` | **Aucun token typographique** dans `@theme` : pas un seul `--text-*`, `--tracking-*` ou `--leading-*`. Conséquence directe : `tracking-[…]` apparaît **43×** dans `src/components`, avec 3 valeurs pour la *même* famille visuelle « label caps » — `0.16em` (CTA hero), `0.2em` (labels stats), `0.26em` (Overline + badge hero). Et 7 tailles de titre en dur (`text-[10.5px]`, `[11px]`, `[21px]`, `[22px]`, `[31px]`, `[40px]`, `[54px]`). | Créer l'échelle dans `@theme` avec les modificateurs Tailwind v4 (`--text-x--line-height`, `--text-x--letter-spacing`, `--text-x--font-weight`) — voir §2. Unifier sur **deux** trackings : `--tracking-caps: 0.2em` et `--tracking-caps-wide: 0.26em`, supprimer `0.16em`. | L | |
| 15 | mineur | 4 | `Overline.tsx:4` vs `HeroHome.tsx:28` vs `StatsSection.tsx:38` | **Trois implémentations du même filet or**, avec trois largeurs desktop différentes : `w-[26px] sm:w-12` (48 px, Overline), `w-[52px]` (hero badge, en blanc), `w-[26px] sm:w-[52px]` (stats). Plus `.rule-grow` dans `globals.css:268` qui est une 4ᵉ variante animée. | Composant `src/components/ui/Rule.tsx` : `<Rule tone="gold" | "invert" size="sm" | "md" grow?>` avec une largeur unique (`w-6.5 sm:w-13`) et le `aria-hidden` intégré. Réutilisable par `Overline` (via composition) et par les 3 sites. | S | ✅ |
| 16 | mineur | 3 | `HeroHome.tsx:24` | `saturate-[.92]` applique un **filtre CSS sur l'élément LCP**. Un `filter` force la promotion en couche de composition et retarde légèrement le paint de l'image la plus critique de la page. C'est aussi un traitement colorimétrique qui n'a rien à faire dans la couche CSS. | Cuire la désaturation dans l'asset (`magick … -modulate 100,92,100`) et supprimer la classe. Si on tient à la garder côté CSS : `saturate-92` (Tailwind v4 accepte `saturate-<number>`), pas d'arbitraire. | S | ✅ |
| 17 | mineur | 3 | `messages/en.json` `IndexPage.seo.metaDescription` | « 30+ years' experience » alors que la page affiche 37 ans et « Est. 1989 » (2026 − 1989 = 37 ✅, les 3 chiffres on-page sont cohérents entre eux). La meta sous-vend et introduit une incohérence entre le SERP et la page. | Passer à « 37 years' experience ». **Ne pas toucher au FR** : conformément à la règle projet, la meta FR cible des mots-clés sans rapport et se rédige sur ses propres données. | S | ✅ |
| 18 | mineur | 1 | `CountUp.tsx:39-41` | `setValue(0)` est appelé **au montage**, indépendamment de la visibilité, avant que l'observer ne déclenche. Sur un grand écran, `StatsSection` est partiellement visible dès le chargement → l'utilisateur voit « 37 » (SSR) → « 0 » (hydratation) → animation. Flash de contenu incorrect. | Déplacer le `setValue(0)` **dans le callback de l'observer**, juste avant `run()`. Le SSR reste correct, plus de flash. | S | ✅ |
| 19 | mineur | 4 | `globals.css:2` + `select.tsx:85` | `tw-animate-css` est importé globalement pour **un seul usage** (`select.tsx:85`), tandis que `.reveal`, `.float-slow`, `.bounce-slow`, `.pulse-slow`, `.rule-grow` sont réimplémentés à la main dans `globals.css:239-275`. Deux systèmes d'animation coexistent. (`motion` est bien absent du projet ✅.) | Soit exprimer `.reveal` avec les primitives `tw-animate-css` (`animate-in fade-in slide-in-from-bottom-6`), soit — puisqu'on part de toute façon sur `animation-timeline: view()` (constat #6) — garder le CSS maison et vérifier avec knip que `tw-animate-css` reste justifié par son unique usage. | S | |
| 20 | mineur | 2 | `StatsSection.tsx:23` | `p-[40px_28px]` : arbitraire **composé**, illisible et non triable par le plugin de tri de classes. | `px-7 py-10` (28 = 7×4, 40 = 10×4 — échelle native). Même motif dans `DesignCta.tsx:11`. | S | ✅ |
| 21 | mineur | 3 | `StatsSection.tsx:59-66` et `82-84` | Le `+` de « 500+ » est un `<span aria-hidden>` séparé (l.61-66) → un lecteur d'écran lit « 500 », pas « 500 plus ». Symétriquement, `production.unit` (« Million pieces ») à la l.82-84 **n'est pas** `aria-hidden` mais le `3.5` du `CountUp` l'est partiellement. Traitement incohérent. | Envelopper valeur + suffixe dans un conteneur unique portant `aria-label={t("craftsmen.value")}` (ex. « plus de 500 »), et laisser les enfants `aria-hidden`. Cohérent avec la restructuration en `<dl>` (#12). | S | |
| 22 | nit | 2 | `HeroHome.tsx:50` | `flex flex-col flex-wrap` : `flex-wrap` n'a aucun effet en direction colonne sans hauteur contrainte. Bruit. | `flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:justify-center lg:justify-start`. L'empilement mobile / côte-à-côte dès `sm` est **conforme** à la règle projet ✅. | S | ✅ |
| 23 | nit | 2 | `HeroHome.tsx:53,59` | `py-[18px]` mobile puis `lg:py-[17px]` : 1 px d'écart, invisible, mais qui coûte deux valeurs arbitraires et une media query. | Supprimer `lg:py-[17px]`, garder `py-4.5` (18 px) partout. | S | ✅ |
| 24 | nit | 5 | `StatsSection.tsx:25-26` | `alt=""` **et** `aria-hidden="true"` sur la même image. `alt=""` implique déjà `role="presentation"` ; l'attribut est redondant. | Garder `alt=""` seul. | S | ✅ |
| 25 | nit | 1 | `CountUp.tsx:80-86` | Le DOM contient **deux fois** le nombre (span `invisible` de réservation de largeur + span absolu). Bon pour le CLS ✅ (aucun reflow), mais le HTML source contient « 3737 » côté crawler. | Acceptable en l'état. Si on veut être propre : `aria-hidden` sur le span de réservation et `<span className="sr-only">` pour la valeur finale — ou utiliser `font-variant-numeric: tabular-nums` + `min-w-[Nch]` et supprimer le doublon. | S | |
| 26 | nit | 3 | `page.tsx:66-93` | Trois blocs `<script type="application/ld+json">` distincts émis **avant** `<HeroHome />` dans le flux HTML, donc avant l'élément LCP. Par ailleurs `webPageJsonLd` (`structured-data.ts:120-135`) n'expose ni `primaryImageOfPage` ni `about: { "@id": "#organization" }` — alors que `aboutPageJsonLd` (l.150) le fait. | Fusionner les trois en un seul nœud `@graph` (une seule balise, pas de `@context` répété) et le rendre **après** les sections. Ajouter `primaryImageOfPage` pointant sur l'image hero et `about: { "@id": … #organization }`. | S | |
| 27 | nit | 5 | `public/home/slider/az4948_ggpzcg.webp` | (a) Nom de fichier généré (style Cloudinary), zéro valeur SEO/maintenance, à l'opposé du hero correctement nommé. (b) L'image contient un **filigrane tiers** incrusté en haut à droite : un monogramme « A » stylisé vert et gris, qui n'est pas le logo ADVMFR. Atténué à 16 % mais présent dans l'asset. | Renommer sur le modèle du hero (`mother-of-pearl-set-advance-manufacturer.webp`) et recadrer/retirer le filigrane. | S | ✅ |

---

## 2. Inventaire exhaustif des valeurs arbitraires

### `HeroHome.tsx` — 24 occurrences / 20 valeurs distinctes

| Ligne | Valeur arbitraire | Remplacement proposé | Nature |
|---|---|---|---|
| 13 | `pt-[75px]` | `pt-header` (token `--spacing-header: 75px`) | token `@theme` |
| 13 | `lg:h-[calc(640px+89px)]` | `lg:min-h-[calc(var(--spacing-hero)+var(--spacing-header-lg))]` (tokens `--spacing-hero: 640px`, `--spacing-header-lg: 90px`) — **et `min-h`, pas `h`** (constat #9) | tokens + fix |
| 13 | `lg:pt-[89px]` | `lg:pt-header-lg` | token `@theme` |
| 16 | `min-h-[46vh]` | conserver (`vh` volontaire — `dvh` provoquerait du CLS sur mobile) ; extraire en `@utility hero-media` si réutilisé | garder |
| 16 | `flex-[1_1_480px]` | `flex-1 basis-120` (480 px = 120 × `--spacing`) | échelle native v4 |
| 24 | `saturate-[.92]` | `saturate-92`, ou cuire dans l'asset (préféré, constat #16) | échelle native / asset |
| 28 | `w-[52px]` | `w-13` → puis `<Rule tone="invert" />` (constat #15) | échelle + composant |
| 29 | `text-[10.5px]` | `text-micro` (token `--text-micro: 10.5px`) | token `@theme` |
| 29 | `tracking-[0.26em]` | `--text-micro--letter-spacing: 0.26em` (porté par le token de taille) | token `@theme` |
| 36 | `flex-[1_1_480px]` | `flex-1 basis-120` | échelle native v4 |
| 36 | `pb-[76px]` | `pb-section-sm` (token `--spacing-section-sm: 76px`) | token `@theme` |
| 37 | `max-w-[560px]` | `max-w-140` | échelle native v4 |
| 38 | `mb-[30px]` | `mb-7.5` | échelle native v4 |
| 43 | `mb-[26px]` | `mb-6.5` | échelle native v4 |
| 43 | `text-[40px]` | `text-display-sm` (token `--text-display-sm: 40px`) | token `@theme` |
| 43 | `leading-[1.08]` | `--text-display-lg--line-height: 1.08` | token `@theme` |
| 43 | `tracking-[-0.01em]` | `--text-display-lg--letter-spacing: -0.01em` | token `@theme` |
| 43 | `lg:text-[clamp(36px,4.6vw,58px)]` | `lg:text-display-lg` (token `--text-display-lg: clamp(36px,4.6vw,58px)`) | token `@theme` |
| 47 | `leading-[1.65]` | `leading-relaxed` (1.625 — écart 0,025 invisible) | échelle native |
| 47 | `max-w-[480px]` | `max-w-120` | échelle native v4 |
| 53, 59 | `py-[18px]` | `py-4.5` | échelle native v4 |
| 53, 59 | `text-[11px]` | `text-label` (token `--text-label: 11px`, avec `--font-weight: 600` et `--letter-spacing: var(--tracking-caps)`) | token `@theme` |
| 53, 59 | `tracking-[0.16em]` | **supprimer** — unifier sur `--tracking-caps: 0.2em` (constat #14) | token `@theme` |
| 53, 59 | `lg:py-[17px]` | **supprimer** (constat #23) | suppression |

### `StatsSection.tsx` — 28 occurrences / 22 valeurs distinctes

| Ligne | Valeur arbitraire | Remplacement proposé | Nature |
|---|---|---|---|
| 11 | `py-[76px]` | `py-section-sm` | token `@theme` |
| 11 | `lg:py-[92px]` | `lg:py-section` (normaliser 92/104/110 → 104) | token `@theme` |
| 12 | `max-w-[1440px]` (+ `px-6 lg:px-16`) | `@utility shell` (absorbe 35 + 40 occurrences repo-wide) | `@utility` |
| 16 | `-translate-y-[42%]` | `-translate-y-2/5` (40 % — v4 accepte les fractions) | échelle native v4 |
| 16 | `text-[clamp(90px,13vw,176px)]` | `text-watermark` (token `--text-watermark`) | token `@theme` |
| 16 | `text-foreground/[0.055]` | `text-foreground/5` | échelle native |
| 21 | `z-[1]` | `z-1` (v4 accepte `z-<number>`) | échelle native v4 |
| 23 | `p-[40px_28px]` | `px-7 py-10` | échelle native |
| 23 | `lg:min-h-[360px]` | `lg:min-h-90` | échelle native v4 |
| 30 | `opacity-[.16]` | `opacity-16` (v4 accepte `opacity-<number>`) | échelle native v4 |
| 36 | `text-[54px]` | `text-stat` (token `--text-stat: 54px`) | token `@theme` |
| 36 | `leading-[.9]` | `--text-stat-lg--line-height: .9` | token `@theme` |
| 36 | `lg:text-[clamp(60px,7vw,88px)]` | `lg:text-stat-lg` | token `@theme` |
| 38 | `w-[26px]` | `w-6.5` → `<Rule />` | échelle + composant |
| 38 | `sm:w-[52px]` | `sm:w-13` — **et aligner** avec `Overline` qui utilise `sm:w-12` (48 px) : choisir une seule valeur | échelle + composant |
| 40, 68, 86 | `text-[11px]` | `text-label` | token `@theme` |
| 40, 68, 86 | `tracking-[0.2em]` | porté par `--text-label--letter-spacing` | token `@theme` |
| 40 | `mt-[22px]` | `mt-5.5` | échelle native v4 |
| 43 | `leading-[1.6]` | `leading-relaxed` | échelle native |
| 43 | `max-w-[300px]` | `max-w-75` | échelle native v4 |
| 59, 63, 80, 82 | `lg:text-[clamp(46px,5vw,64px)]` | `lg:text-stat-md` | token `@theme` |
| 71, 89 | `max-w-[220px]` | `max-w-55` | échelle native v4 |

### `Overline.tsx` (périmètre annexe)

| Ligne | Valeur | Remplacement |
|---|---|---|
| 4 | `w-[26px] sm:w-12` | `<Rule />` (constat #15) |
| 45 | `text-[11px]` + `tracking-[0.26em]` | `text-label-wide` (token dédié) |

### Bloc `@theme` proposé

```css
@theme {
  /* Header — valeurs STATIQUES (header non scrollé), pour les padding-top de page.
     Ne pas confondre avec --header-h publié par HeaderShell, qui varie au scroll. */
  --spacing-header: 75px;
  --spacing-header-lg: 90px;
  --spacing-hero: 640px;

  /* Rythme vertical réel (remplace 76/92/104/110 en dur) */
  --spacing-section-sm: 76px;
  --spacing-section: 104px;

  /* Tracking — deux valeurs, pas trois */
  --tracking-caps: 0.2em;
  --tracking-caps-wide: 0.26em;

  /* Échelle typographique éditoriale (font-display / Tenor Sans) */
  --text-micro: 10.5px;
  --text-micro--letter-spacing: var(--tracking-caps-wide);
  --text-micro--font-weight: 600;

  --text-label: 11px;
  --text-label--letter-spacing: var(--tracking-caps);
  --text-label--font-weight: 600;

  --text-display-sm: 40px;
  --text-display-sm--line-height: 1.08;

  --text-display-lg: clamp(36px, 4.6vw, 58px);
  --text-display-lg--line-height: 1.08;
  --text-display-lg--letter-spacing: -0.01em;

  --text-stat: 54px;
  --text-stat--line-height: 0.9;
  --text-stat-md: clamp(46px, 5vw, 64px);
  --text-stat-lg: clamp(60px, 7vw, 88px);
  --text-stat-lg--line-height: 0.9;
  --text-watermark: clamp(90px, 13vw, 176px);

  /* Contraste AA (constat #3) : 4.60:1 sur surface-container-low */
  --color-gold: #856b33;
}

@utility shell {
  margin-inline: auto;
  max-inline-size: 90rem;      /* 1440px */
  padding-inline: 1.5rem;      /* 24px */
  @media (width >= 64rem) { padding-inline: 4rem; }  /* 64px */
}
```

**Mobile-first** : `HeroHome` et `StatsSection` sont correctement écrits base → `sm:` → `lg:`, sans `max-*` inversé. Le seul reset descendant est `lg:min-h-0` (`HeroHome.tsx:16`), qui est le pattern légitime d'annulation d'une contrainte mobile. ✅ Rien à corriger sur cet axe.

---

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

### Actuel

```
app/[locale]/page.tsx                             [RSC]  ✅ statique, setRequestLocale
│
├── 3 × <script application/ld+json>              [RSC]  ⚠ émis AVANT le LCP (#26)
│
├── HeroHome                                      [RSC]  ✅ 100 % serveur, 0 kB de JS
│   ├── Overline                                  [RSC]  ✅
│   ├── next/image (priority, blur)               [RSC]  ✅
│   └── 2 × next-intl Link                        [RSC]  ✅ (pas de handler client)
│
└── StatsSection                                  [RSC]
    ├── Reveal ×2                        ─────►  ['use client']
    │   └── children                              [RSC]  ✅ pattern correct : le contenu
    │       ├── next/image (fill)                        serveur passe par la prop
    │       ├── CountUp ×3               ─────►  ['use client']   `children`, il n'entre
    │       └── labels / <p>                      [RSC]  PAS dans le bundle client
    │
    └── Coût client réel :
        • 25 IntersectionObserver (Reveal, Home entière) + 3 (CountUp) = 28
        • 3 × useLocale()  → dépendance au NextIntlClientProvider
        • ~324 re-renders React + 324 × new Intl.NumberFormat pendant l'animation
```

**Verdicts sur les questions posées :**
- `Reveal` enveloppant du contenu serveur via `children` : **OK, c'est le bon pattern**. Les enfants sont rendus sur le serveur et transitent dans le payload RSC ; rien de leur code n'atterrit dans le bundle. Aucune fuite constatée.
- Le hero est bien **100 % Server Component**, zéro JS. ✅ C'est le point fort du fichier.
- `CountUp` rend **déjà** la valeur finale côté serveur (`useState(target)`, `CountUp.tsx:26`) → crawlable et fonctionnel sans JS. ✅ La boîte est cochée ; il reste seulement le `setValue(0)` prématuré (#18) et le coût de rendu (#7).

### Cible

```
app/[locale]/page.tsx                             [RSC]
│
├── HeroHome                                      [RSC]  inchangé ✅
│
├── StatsSection                                  [RSC]
│   ├── <div class="reveal">                      [RSC]  0 JS
│   │      globals.css : animation-timeline: view()
│   │                    animation-range: entry 0% cover 30%
│   │      derrière @supports, fallback = état visible
│   │
│   └── <StatCounter formatted="37" target={37}/> ['use client'] — feuille minimale
│          • `formatted` calculé côté SERVEUR (getFormatter de next-intl)
│            → plus de useLocale(), plus de NextIntlClientProvider requis ici
│          • animation par ref + rAF → 0 re-render React
│          • observer : soit animation-timeline, soit 1 singleton module-level
│
└── <script @graph>                               [RSC]  après les sections (#26)

Coût client Home : 28 IntersectionObserver ──► 0 (option CSS) ou 1 (option singleton)
                   ~324 re-renders          ──► 0
```

Le bon compromis si `animation-timeline: view()` est jugé trop récent pour la baseline du client : **option (b)** — un `IntersectionObserver` unique au niveau module dans `Reveal.tsx`, partagé par les 65 usages du repo. Gain immédiat, aucun risque de support.

---

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

| Composant:ligne | Fichier | Dimensions / poids | `alt` EN | `alt` FR | `priority` / `loading` | `sizes` | Rôle | Verdict |
|---|---|---|---|---|---|---|---|---|
| `HeroHome.tsx:17-25` | `public/home/white-gold-set-advance-manufacturer.webp` | **3000×2000**, 284 860 B (278 Ko), sRGB | « A finished white gold and silver jewelry set crafted in the Advance Manufacturers workshop » | « Parure en or blanc et argent fabriquée dans les ateliers Advance Manufacturers » | `priority` ✅ (LCP), `placeholder="blur"` ✅ | `(min-width:1024px) 50vw, 100vw` — **correct** : les deux moitiés sont `flex-[1_1_480px]` à base et grow identiques, donc exactement 50 % chacune dès `lg` ; 100vw en dessous. ✅ | informative | ❌ voir ci-dessous |
| `StatsSection.tsx:24-31` | `public/home/slider/az4948_ggpzcg.webp` | **3000×2000**, 279 404 B (273 Ko), sRGB | `""` | `""` | lazy (défaut) ✅ | `(min-width:1024px) 50vw, 100vw` — **faux au-delà de 1440** (#8) | décorative (`opacity .16`) | ⚠ `alt=""` correct ; poids + `sizes` + filigrane à corriger |

**Contenu réel du hero (vérifié visuellement)** : packshot studio sur fond blanc pur, quatre pièces d'une même parure en métal blanc à texture striée « plume », serties de petites pierres **violettes (améthystes)** — pendentif ovale sur chaîne (gauche), bague bombée (haut-centre), créole ouverte et anneau large (droite). Aucun atelier, aucune main, aucun outil.

Trois défauts dans l'alt EN/FR :
1. « white gold **and** silver » / « or blanc **et** argent » — le métal est uniforme sur les quatre pièces, il ne peut pas être les deux.
2. Les améthystes, seul élément coloré de l'image, ne sont pas mentionnées.
3. « crafted in the Advance Manufacturers workshop » / « fabriquée dans les ateliers » est une inférence marketing non visible sur un packshot fond blanc.

Proposition :
- **EN** (98 car.) : `White gold jewellery set: textured pendant necklace, ring and hoop earrings set with amethysts`
- **FR** (95 car.) : `Parure en or blanc texturé : pendentif sur chaîne, bague et créoles serties d'améthystes`

**Contenu réel de l'image stats** : parure « fleur/soleil » ajourée en métal blanc à cabochons de nacre — pendentif, bague, deux dormeuses. **Un filigrane tiers (monogramme « A » vert et gris) est incrusté en haut à droite**, qui n'est pas le logo ADVMFR (#27).

**CLS** : aucun risque identifié sur les deux images — les conteneurs ont des hauteurs déterministes (`min-h-[46vh]` en `vh`, donc stable sur mobile ; `lg:min-h-[360px]`), `fill` + `object-cover` n'induit pas de reflow, et `Reveal` anime uniquement `transform`/`opacity` (exclus du calcul CLS). Le `CountUp` réserve sa largeur finale via le span invisible (`CountUp.tsx:81-83`) — bien vu. **Le seul risque de shift de la page vient de `AboutHero.tsx:10` / `DesignHero.tsx:13`** qui utilisent `var(--header-h)` en `padding-top` alors que cette variable diminue au scroll (#10) — hors périmètre strict mais directement lié.

---

## 5. Top 5 des actions les plus rentables

1. **Rendre le badge hero lisible + recadrer l'asset LCP** (#1, #2) — Le hero, seul élément vraiment critique de la page, a deux défauts visibles à l'œil nu : un texte blanc sur blanc (contraste 1,05:1, mesuré) et un `object-cover` qui supprime 67 % du sujet sur mobile. Un scrim en dégradé (5 lignes) règle le premier ; un asset recadré règle le second. **Effort S+M, impact bloquant, aucune régression possible.**

2. **`--color-gold: #856b33` + `hover:text-foreground` sur les CTA** (#3, #4) — Un changement de token dans `globals.css` fait passer d'un coup **toute la Home** de 3,12:1 à 4,60:1 sur les overlines et labels stats, et une classe sur le CTA le fait passer de 3,26:1 à 4,98:1 au survol. Même teinte, identité préservée. **Deux lignes, effort S, corrige la non-conformité WCAG AA la plus répandue du site.**

3. **Poser les tokens `@theme` typo + espacement et l'`@utility shell`** (#13, #14) — Absorbe **45 valeurs arbitraires** sur ces deux seuls fichiers, et repo-wide : 43 `tracking-[…]`, 35 `max-w-[1440px]`, 40 `px-6 lg:px-16`. Tue au passage 5 tokens `@theme` morts et unifie 5 valeurs de padding de section. C'est le seul chantier L de la liste, mais c'est celui qui empêche la dette de se reformer à chaque nouvelle section. **Effort L, ROI structurel.**

4. **`Reveal` en CSS pur (`animation-timeline: view()`) ou observer singleton** (#6) — Fait passer la Home de **28 `IntersectionObserver` à 0 ou 1**, et supprime un composant client de l'arbre. Bénéficie aux 65 usages du repo, pas seulement à la Home. Le fallback `@supports` rend le changement sans risque. **Effort M, gain INP mesurable.**

5. **`CountUp` sans re-render + JSON-LD `foundingDate`/`numberOfEmployees`** (#7, #11, #18) — Trois corrections chirurgicales : passer l'animation en écriture DOM par `ref` (−324 re-renders et −324 `Intl.NumberFormat`), déplacer le `setValue(0)` dans le callback de l'observer (supprime le flash 37→0), et ajouter deux champs au `organizationJsonLd` pour que les chiffres phares de la page (1989, 500) soient enfin structurés. **Effort M+S, gain perf + SEO.**

---

**Note de coordination** : j'ai transmis à `audit-alts` mes données brutes sur les deux images (dimensions, poids, contenu visuel vérifié, alts proposés EN/FR, filigrane tiers, problème de cadrage `object-cover`, mesure de luminance du badge) pour éviter qu'il refasse le travail.
