# Audit SEO/IA — Conformité specification.website — 2026-06-01

> Référentiel : The Website Specification (`specification.website`, via MCP). Périmètre : 10 catégories, statuts **required + recommended + avoid** (`optional` exclu). Vérification : code (statique) + runtime (headers/HTML servis, routes spéciales) + Lighthouse (lab).

## Score de couverture

- **Items audités : 103 / 103 (100%)** — aucun item manquant, aucun doublon.
- Verdicts : ✅ **52 pass** · ⚠️ **20 partial** · ❌ **17 fail** · ➖ **14 na**.
- Lighthouse (lab, build prod :3001) : / perf=80 a11y=97 bp=73 seo=100 LCP=5.3s CLS=0 · /guides/regulation-energetique-2026/ perf=90 a11y=97 bp=73 seo=100 LCP=3.7s CLS=0 · /outils/comparateur-electricite-pro/ perf=79 a11y=93 bp=73 seo=100 LCP=3.5s CLS=0.242
- Build audité : **prod** (`next start` :3001) — un `next dev` tournait sur :3000, non touché.
- linkinator : inconclusif (recurse 147 pages hors budget) → maillage jugé via dumps HTML + sitemap.
- Note méthodo : `curl localhost` bloqué par le sandbox pour les sous-agents → preuves runtime tirées des artefacts `.audit/` capturés en amont (headers/HTML/routes/Lighthouse réels).

## Matrice par catégorie

Légende verdict : ✅ pass · ⚠️ partial · ❌ fail · ➖ na — Statut : R=required, r=recommended, A=avoid (❌ avoid = anti-pattern présent).

### Foundations — ✅8 ⚠️4 ❌1 ➖1

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| The HTML doctype | R | ✅ | All 5 rendered dumps begin with `<!DOCTYPE html>` as the very first bytes (no BOM/whitespace before it). page-home.html offset 0: `<!DOCTYPE html><html lang="fr | — | S |
| The lang attribute on <html> | R | ✅ | `<html lang="fr" suppressHydrationWarning>` in app/layout.tsx:131; rendered as `<html lang="fr">` in every dump. `fr` is a valid BCP 47 tag matching the FR cont | — | S |
| <meta charset> | R | ✅ | `<meta charSet="utf-8"/>` is the first child of <head>, byte offset 43 in page-home.html (well within the 1024-byte window). HTTP header also agrees: `Content-T | — | S |
| <meta viewport> | R | ✅ | `<meta name="viewport" content="width=device-width, initial-scale=1"/>` present in <head> of all dumps (page-home.html). No `user-scalable=no` / `maximum-scale= | — | S |
| The <title> element | R | ✅ | Exactly one non-empty <title> per page (grep count = 1 on home). Unique per route and page-specific-first with site suffix via template `%s \| Acieb Énergie` (l | — | S |
| <meta name="color-scheme"> | r | ❌ | No `<meta name="color-scheme">` in any rendered dump (grep color-scheme count = 0 across all 5 pages), and no `color-scheme` CSS property emitted in the head. N | Add `colorScheme: 'light'` (or `'light dark'` if dark mode is audited) to the Next viewport export in app/layout.tsx, and set `:root { color-scheme: light; }` i | S |
| Canonical URL (rel="canonical") | r | ⚠️ | Self-referencing absolute canonical present on home, guides, outils, secteurs: `<link rel="canonical" href="https://www.aciebenergie.fr/..."/>` (matching og:url | Add `alternates: { canonical: '/contact' }` to app/contact/page.tsx metadata; better, centralise self-referencing canonicals via metadataBase + a shared helper  | M |
| Favicons and app icons | r | ⚠️ | PNG icon set wired up well: `<link rel="icon" .../icon 32x32>`, icon2 192x192, icon3 512x512, `<link rel="apple-touch-icon" .../apple-icon 180x180>`, `<link rel | Add app/favicon.ico (multi-res 16/32/48) or public/favicon.ico, optionally an SVG favicon, and a 512x512 maskable icon entry in app/manifest.ts. | M |
| <meta name="theme-color"> | r | ⚠️ | PWA manifest sets `theme_color: "#FF6B2B"` and `background_color: "#ffffff"` (app/manifest.ts:11-12), used at install/launch. But NO `<meta name="theme-color">` | Add `themeColor` to Next viewport export in app/layout.tsx (e.g. `export const viewport = { themeColor: [{ media: '(prefers-color-scheme: light)', color: '#ffff | S |
| Feed content hygiene | r | ⚠️ | app/feed.xml/route.ts is well-formed: declares xmlns:atom/media/content, ships `<atom:link rel="self" href=.../feed.xml>` (line 65), stable `<guid isPermaLink=" | Add xmlns:sy + `<sy:updatePeriod>weekly</sy:updatePeriod><sy:updateFrequency>1</sy:updateFrequency>` to the channel in app/feed.xml/route.ts; optionally derive  | S |
| Popover API | r | ➖ | No native HTML Popover API usage: zero `popover`/`popovertarget` attributes in any rendered dump (grep popover count = 0 across all 5 pages). The `popover.tsx`  | — | M |
| <meta name="description"> | r | ✅ | Non-empty, page-specific descriptions on every page. Home: `Courtier en énergie B2B : nos ingénieurs renégocient vos contrats d'électricité et gaz. Rémunération | — | S |
| Open Graph protocol | r | ✅ | Full OG set on home: og:title, og:description, og:url (matches canonical), og:site_name `Acieb Énergie`, og:locale `fr_FR`, og:type `website`, og:image `https:/ | — | S |
| Feed discovery with rel="alternate" | r | ✅ | `<link rel="alternate" type="application/rss+xml" title="Acieb Énergie - Blog RSS Feed" href="/feed.xml"/>` hard-coded in app/layout.tsx:143-148, rendered in <h | — | S |

### SEO — ✅8 ⚠️2 ❌0 ➖1

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Heading hierarchy | R | ⚠️ | Un seul h1 par page sur les 5 dumps (h1 count = 1 partout). Séquences propres sans saut sur home (h1→h2→h3), guides, outils, secteurs/industrie. | Sur la page contact, remonter les blocs de section de h3 → h2 (ou insérer un h2 de section englobant), pour obtenir h1→h2→(h3). Préférer un composant Heading av | S |
| Redirects (301/302/308) | R | ✅ | next.config.ts:118-205 — 78 règles toutes `permanent: true` (308). Migrations legacy: /blog/* → /actualites\|/reglementation\|/guides\|/outils, /guides/* obsolè | — | M |
| Meta robots and X-Robots-Tag | R | ✅ | Défaut global index/follow : app/layout.tsx:44-55 (robots.index:true, follow:true, googleBot max-image-preview:large, max-snippet:-1). Dumps confirment `<meta n | — | S |
| Breadcrumbs | r | ⚠️ | Composant accessible correct : components/ui/breadcrumb.tsx:12 `<nav aria-label="breadcrumb">`, <ol> (l.19), aria-current="page" sur l'élément courant (l.68). B | Étendre le fil d'Ariane (visible <nav aria-label="breadcrumb"> + BreadcrumbList JSON-LD synchronisé) à toutes les pages de niveau ≥2 : secteurs/*, solutions/*,  | M |
| Sitemap index files | r | ➖ | 147 URLs au total (grep `<loc>` = 147), très en dessous du plafond de 50 000 URLs / 50 MB. Un sitemap unique <urlset> (root = urlset, pas sitemapindex : grep si | — | S |
| robots.txt | r | ✅ | app/robots.ts génère le fichier ; servi 200 en text/plain (special-routes-status.txt: `robots.txt -> HTTP 200 \| content-type: text/plain`). Groupes par user-ag | — | S |
| XML sitemaps | r | ✅ | app/sitemap.ts génère 147 URLs (grep `<loc>` = 147), servi 200 application/xml (special-routes-status.txt). URLs absolues canoniques avec trailing slash, aucune | — | M |
| URL structure | r | ✅ | all-urls.txt (147 URLs) : 100% minuscules, hyphens (jamais underscore), aucune majuscule (grep [A-Z]\|_ = vide). Trailing slash uniforme forcé par next.config.t | — | S |
| Internal linking | r | ✅ | Chaque dump porte un maillage interne riche en `<a href="/...">` réels (home 30, guides 31, outils 36, industrie 28, contact 23 liens internes hors _next/api).  | — | M |
| Structured data (JSON-LD) | r | ✅ | JsonLd renderer sécurisé (lib/json-ld.tsx:15-24, échappe < en \u003c). Knowledge-graph @id centralisés (GRAPH_IDS : organization/website/logo/service/webpage, j | — | M |
| Soft 404s | A | ✅ | Les routes inexistantes renvoient un vrai 404, pas 200 : special-routes-status.txt — `.well-known/security.txt -> HTTP 404`, `.well-known/api-catalog -> HTTP 40 | — | S |

### Agent Readiness — ✅6 ⚠️1 ❌2 ➖0

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Stable URLs | R | ✅ | next.config.ts enforces a single canonical host (allowedOrigins www.aciebenergie.fr) and one path style via trailingSlash:true + skipTrailingSlashRedirect:false | — | — |
| HTTP Link headers for discovery | r | ❌ | The only Link response header on the homepage is the Next.js default link: <https://www.aciebenergie.fr/>; rel="canonical" (.audit/headers-home.txt; same canoni | Add a Link header to next.config.ts headers() (source '/' or '/(.*)') advertising IANA-registered relations: Link: </llms.txt>; rel="describedby"; type="text/ma | S |
| Agent Skills discovery | r | ❌ | No /.well-known/agent-skills/index.json and no SKILL.md artefacts: no app route under *well-known*, no public/.well-known directory, grep for 'agent-skills' acr | If you want to ship it: author a SKILL.md (YAML frontmatter name+description) teaching agents to use /services.md, /llms.txt and the .md endpoints; host at /.we | M |
| Per-page Markdown source endpoints | r | ⚠️ | A site-level markdown surface exists (/services.md returns Content-Type text/markdown; charset=utf-8, app/services.md/route.ts) and the curated /llms-full.txt c | Add a catch-all .md route handler (e.g. app/[category]/[slug]/[...] or a /guides/[slug].md style route) that serves the raw MDX body of each content-collections | M |
| Agent readiness (umbrella) | r | ✅ | Site ships the core agent-readiness signals: stable URLs (above), JSON-LD structured data on every audited page (2 ld+json blocks on home with @id graph #organi | — | S |
| /llms.txt | r | ✅ | app/llms.txt/route.ts returns 200 with Content-Type text/plain; charset=utf-8 (manifest: llms.txt=200, llms-full.txt=200). Correct structure per llmstxt.org: H1 | — | S |
| robots.txt for AI crawlers | r | ✅ | app/robots.ts (rendered in .audit/route-robots_txt.txt, manifest robots.txt=200) sets explicit per-agent policy in 4 groups: (1) full access for Googlebot/Bingb | — | — |
| Structured data for agents | r | ✅ | JSON-LD present and server-rendered (pre-JS in dumps). Home: 2 ld+json blocks with Organization/WebSite/Service/OfferCatalog/Offer/SearchAction/ContactPoint/Pos | — | S |
| Machine-readable formats | r | ✅ | Multiple non-HTML representations served: sitemap.xml (200, app/sitemap.ts, 147 URLs), RSS feed at /feed.xml (200) with a discoverable <link rel="alternate" typ | — | — |

### Well-Known URIs — ✅0 ⚠️0 ❌2 ➖0

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Well-known URIs | r | ❌ | No /.well-known/ namespace is served by the site. `find` over app/ and public/ returns zero matches for *well-known* (no app/.well-known/, no public/.well-known | Establish a /.well-known/ presence in Next.js App Router. Since the framework wraps file responses, serve well-known resources via route handlers under app/.wel | M |
| /.well-known/api-catalog | r | ❌ | .audit/manifest.json records `.well-known/api-catalog`: 404. No app/.well-known/ directory exists (find returns no matches), no public/.well-known/api-catalog f | Create app/.well-known/api-catalog/route.ts returning a valid Linkset (RFC 9264) with Content-Type: application/linkset+json; charset=utf-8 and HTTP 200. Anchor | M |

### Performance — ✅3 ⚠️6 ❌7 ➖1

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Core Web Vitals (LCP, INP, CLS) | R | ❌ | Lab LCP > seuil 2.5s sur les 3 pages mesurées : home 5.3s (lh-0, score 0.21, élément LCP = <video> hero, page-0-VIDEO), guide 3.7s (lh-1, score 0.59, <img> hero | 1) Home : donner un poster optimisé (AVIF/WebP) au <video> et le preloader (<link rel=preload as=image fetchpriority=high>) car c'est l'élément LCP ; envisager  | L |
| Image optimisation | R | ⚠️ | next.config.ts:50-54 configure formats AVIF+WebP et qualities [75,80,85] (bon). image-aspect-ratio=1, image-size-responsive=1, unsized-images=1 (lh-2) : aucune  | Recompresser/redimensionner l'image hero du guide (vérifier sizes/quality next/image). Pour la home, fournir un poster image optimisé à la <video> et/ou réduire | M |
| Cache-Control headers | R | ⚠️ | Headers HTML (headers-home.txt et al.) : Cache-Control: s-maxage=31536000 + ETag présent + x-nextjs-cache: HIT + x-nextjs-prerender:1. Vary inclut Accept-Encodi | Optionnel : expliciter Cache-Control HTML (ex. public, max-age=0, must-revalidate ou s-maxage + stale-while-revalidate) plutôt que de laisser le navigateur en h | S |
| Compression (gzip, brotli, zstd) | R | ✅ | Compression active et efficace : LH network-requests (lh-0) montre HTML transfer 40856 / resource 197398 (ratio 0.21), CSS 0q4uwe 20936/134809 (0.16), JS ~0.27- | — | S |
| No-Vary-Search response header | r | ❌ | Aucun header No-Vary-Search dans les artefacts headers-*.txt (les 5 pages n'exposent que Vary: rsc, next-router-*, Accept-Encoding). Aucune config correspondant | Ajouter dans next.config.ts headers() (source /(.*)) un header No-Vary-Search: params=("utm_source" "utm_medium" "utm_campaign" "utm_content" "utm_term" "gclid" | S |
| Critical CSS and render-blocking resources | r | ❌ | render-blocking-insight=0 sur les 3 pages (lh-0/1/2), Est savings ~450ms : 3 feuilles CSS bloquantes 0q4uwe.wye2jy.css (20936B, wastedMs 605), 0pal0ehffwbcc.css | Difficile à corriger entièrement avec l'App Router Next (gestion CSS interne). Pistes : réduire la taille du bundle CSS principal (0q4uwe = 135KB décompressé),  | M |
| Speculation Rules | r | ❌ | Aucun <script type="speculationrules"> dans le code (grep app/components : 0 occurrence). Next/Link fournit du prefetch RSC (Vary: next-router-prefetch dans les | Optionnel : injecter un <script type=speculationrules> (prerender moderate sur liens HTML same-origin, prefetch conservative en fallback) via le layout, en excl | M |
| View Transitions | r | ❌ | Aucune règle @view-transition ni view-transition-name ni document.startViewTransition dans app/globals.css ou les composants (grep : 0 occurrence). globals.css  | Optionnel : ajouter @view-transition { navigation: auto; } dans globals.css + une règle prefers-reduced-motion: reduce qui désactive l'animation. Faible coût, g | S |
| Visibility-aware rendering | r | ❌ | Aucun content-visibility ni contain-intrinsic-size dans globals.css ou les composants (grep : 0 occurrence). Les pages longues (guide ~175KB HTML, outil avec 9  | Optionnel : appliquer content-visibility: auto + contain-intrinsic-size sur les sections longues répétées (ex. les blocs H2 de l'outil/guide). Bien dimensionner | M |
| Scrollbar gutter | r | ❌ | Aucun scrollbar-gutter: stable dans app/globals.css (:root n'a que --background/--foreground). grep : 0 occurrence dans tout le projet. Le décalage horizontal e | Ajouter dans globals.css : :root { scrollbar-gutter: stable; }. Intervention d'une ligne, gain CLS/UX direct. | S |
| Lazy loading images, iframes, and video | r | ⚠️ | next/image applique loading=lazy + decoding=async par défaut sur les images below-the-fold (unsized-images=1, pas d'images sans dimensions en lh-2). lcp-lazy-lo | Donner au fallback Suspense une min-height proche de la hauteur réelle du wizard (ou un skeleton dimensionné) dans app/outils/comparateur-electricite-pro/page.t | S |
| Preload, prefetch, preconnect | r | ⚠️ | layout.tsx:137-142 : <link rel=preconnect href=analytics.elevaseo.app crossOrigin=anonymous> + dns-prefetch fallback — correctement posé et reconnu par LH (netw | Ajouter priority sur l'<Image> hero du guide (next/image émet preload+fetchpriority=high). Pour la home, <link rel=preload as=image fetchpriority=high> du poste | S |
| Script loading — defer, async, module | r | ⚠️ | Scripts servis par Next (type=module, defer par défaut, pas de <script> bloquant en <head>) — TBT 10-20ms et long-tasks=1 (2 tâches, lh-2) confirment l'absence  | Réduire le JS inutilisé (code-splitting, vérifier optimizePackageImports déjà partiel sur framer-motion/three). Ajuster la cible browserslist pour supprimer les | M |
| Resource hints overview | r | ⚠️ | Hints présents : preconnect + dns-prefetch vers analytics.elevaseo.app (layout.tsx:137-142), reconnus par LH. Next/Link gère prefetch des routes et modulepreloa | Ajouter le preload/fetchpriority=high de l'élément LCP (voir slug preload-prefetch-preconnect). Pas d'autre origine à preconnect. | S |
| HTTP/2 and HTTP/3 | r | ➖ | Serveur local (localhost:3001) sert en http/1.1 (LH network-requests protocol=http/1.1 pour tous les assets de premier-party ; seul le tiers embed.js est en h2) | — | S |
| Web font loading | r | ✅ | next/font/google Inter, subsets:["latin"] (layout.tsx:15), self-hosté et inlinés par Next avec font-display: swap par défaut. font-display-insight=1 (passe) sur | — | S |
| Back/forward cache (BFCache) | r | ✅ | bf-cache=1 (passe) sur les 3 pages (lh-0/1/2) : aucune raison de non-éligibilité détectée par Lighthouse. Le HTML n'est pas servi en Cache-Control: no-store (s- | — | S |

### Accessibility — ✅10 ⚠️5 ❌3 ➖2

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Colour contrast | R | ❌ | Lighthouse color-contrast audit score=0 on ALL 3 pages (lh-0 home, lh-1 guide, lh-2 outils). Failing nodes: orange-600 body text (#f54900 on #fff = 3.59:1, need | Use orange-800 for orange text on white (globals.css already does this for .prose links via --tw-prose-links: orange.800 — extend the same token to UI eyebrows, | M |
| Reduced motion | R | ❌ | No prefers-reduced-motion media query anywhere (grep across app/, components/, lib/, globals.css = 0 hits) and no useReducedMotion/matchMedia gating. The site u | Add a global @media (prefers-reduced-motion: reduce) block in globals.css zeroing animation/transition durations; gate the hero <video> autoplay behind matchMed | M |
| Visible focus indicators | R | ⚠️ | No global :focus-visible rule in app/globals.css (only .prose a:focus at globals.css:95 gives an orange outline for in-article links). Form controls in contact- | Add a global :focus-visible rule in globals.css (outline: 3px solid orange.600; outline-offset: 2px) covering a, button, [role=button], input, select, textarea  | S |
| Semantic HTML and landmarks | R | ⚠️ | Lighthouse landmark-one-main score=1, list/listitem score=1, heading-order score=1 on all pages. Exactly one <h1> per page (home/contact/secteurs/guide/outils). | Wrap the navbar's outer element in a <header> (or add role='banner') and ensure the primary <nav> has aria-label='Principale'. Drop the redundant role='main'/ro | S |
| Accessible form errors | R | ⚠️ | contact-form.tsx renders error messages in text under each field (red <p>{state.errors.x[0]}</p>) and styles invalid fields with a red outline AND text (not col | Add aria-invalid={!!state.errors?.field} to each input; give every error <p> an id and point the field's aria-describedby at it; wrap the success/error toast in | M |
| Accessible data tables | R | ⚠️ | Real <table> markup is used (not div-grids) in the guide page: <table class='w-full bg-white...'> with <thead class='bg-orange-50'> and header cells <th class=' | Add scope='col' to each <thead> <th>, scope='row' to leading row-header cells, and a <caption> (or aria-label on a wrapping region) naming each table. Wrap wide | S |
| Captions and transcripts | R | ➖ | Only time-based media on the site is the hero background <video autoPlay muted loop> (/videos/hero-background.mp4). It is muted, decorative, and conveys no spok | — | S |
| Image alt text | R | ✅ | Lighthouse image-alt score=1 (lh-0, lh-1). Every <img> in page-home.html has an alt attribute (0 imgs without alt of 2 total). Background <video> carries aria-l | — | S |
| Form labels | R | ✅ | Lighthouse label score=1 (lh-0, lh-2). components/contact/contact-form.tsx: every control has an associated <label htmlFor>: firstName, lastName, company, email | — | S |
| Keyboard navigation | R | ✅ | Lighthouse tabindex score=1 and aria-hidden-focus score=1 on all 3 pages (no positive tabindex, no focusable aria-hidden). Interactive controls use native <butt | — | M |
| Descriptive link text | R | ✅ | Lighthouse link-name score=1 on all 3 pages. Footer nav links are named and grouped under aria-labelledby headings; guide list links carry descriptive anchor te | — | S |
| Document and parts language | R | ✅ | Rendered dumps start with <html lang='fr'>. Lighthouse html-has-lang score=1 and html-lang-valid score=1 on all 3 pages. Content is French throughout, matching  | — | S |
| Touch target size | R | ✅ | Lighthouse target-size score=1 on all 3 pages. Primary controls are generously sized: WhatsApp FAB is h-14 w-14 (56px, whatsapp-button.tsx:23), navbar mobile to | — | S |
| Skip links | r | ❌ | No skip link present: grep for 'skip'/'aller au contenu'/'Passer au contenu' in page-home.html returns nothing. app/layout.tsx:151-171 renders <body> -> Consent | Add a visually-hidden-until-focus <a class='skip-link' href='#main'>Aller au contenu</a> as the first child of <body> in app/layout.tsx, give the shared layout  | S |
| CSS state and relational selectors | r | ⚠️ | Modern CSS state selectors ARE used in the form: the phone wrapper uses has-[input:focus-within]:outline-2 and the RGPD toggle uses has-checked: / has-focus-vis | Optionally layer :user-invalid/:user-valid for client-side field feedback and form:has(:user-invalid) to dim the submit button, complementing (not replacing) th | M |
| Hidden until found | r | ➖ | No accordion/collapsible content panels using display:none were found in the audited dumps (no <details> and no JS-toggled hidden panels surfaced in home/contac | — | S |
| ARIA — first rule of ARIA | r | ✅ | Lighthouse aria-allowed-attr, aria-required-attr, aria-valid-attr, aria-valid-attr-value, aria-hidden-focus all score=1 on all 3 pages. ARIA is used appropriate | — | S |
| Native interactive elements | r | ✅ | Interactive controls use native elements throughout: <button type='button'> for navbar triggers and mobile toggle (navbar.tsx:115,267-281), <a href> for links/C | — | S |
| Empty links and buttons | A | ✅ | Lighthouse button-name=1 and link-name=1 on all 3 pages (no empty controls). Icon-only controls all carry accessible names: WhatsApp <a aria-label='Nous contact | — | S |
| Accessibility overlays | A | ✅ | No third-party accessibility-overlay scripts present. grep for acsbapp / userway / equalweb / audioeye / accessibe across page-home.html and page-contact.html r | — | S |

### Security — ✅6 ⚠️1 ❌2 ➖2

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Clickjacking protection (frame-ancestors / X-Frame-Options) | R | ⚠️ | X-Frame-Options: DENY served on / (.audit/headers-home.txt) and set globally in next.config.ts. This blocks all framing in current browsers (legacy mechanism ho | Add `Content-Security-Policy: frame-ancestors 'none'` (ideally as part of a full CSP) alongside the existing X-Frame-Options: DENY in next.config.ts headers(). | S |
| HTTPS and TLS | R | ✅ | Canonical link header forces https://www.aciebenergie.fr/ (.audit/headers-home.txt); HSTS with preload is served, which browsers only honour over HTTPS; next.co | — | S |
| HSTS (Strict-Transport-Security) | R | ✅ | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload served on / (.audit/headers-home.txt) and set globally in next.config.ts headers() for s | — | S |
| X-Content-Type-Options: nosniff | R | ✅ | X-Content-Type-Options: nosniff served on / (.audit/headers-home.txt) and defined globally in next.config.ts headers() (source '/(.*)'). Single correct token, a | — | S |
| Cookie attributes — Secure, HttpOnly, SameSite | R | ✅ | No Set-Cookie header is served on / or other captured routes (.audit/headers-*.txt show no set-cookie). The only first-party cookie is the consent cookie `cc_co | — | S |
| Content Security Policy (CSP) | r | ❌ | No Content-Security-Policy header is served on any captured route (.audit/headers-home.txt and other headers-*.txt have no CSP line). next.config.ts headers() d | Add a nonce-based CSP in next.config.ts (or middleware for per-request nonces): default-src 'self'; script-src 'self' 'nonce-{r}' 'strict-dynamic' plus the self | L |
| /.well-known/security.txt | r | ❌ | .well-known/security.txt returns HTTP 404 (.audit/special-routes-status.txt). No file at public/.well-known/security.txt and no app/.well-known/security.txt/rou | Create public/.well-known/security.txt (served as text/plain) or app/.well-known/security.txt/route.ts with RFC 9116 fields: Contact: mailto:security@aciebenerg | S |
| Subresource Integrity (SRI) | r | ➖ | No external third-party <script src="https://..."> or <link rel=stylesheet href="https://..."> in any HTML dump (.audit/page-home.html, page-contact.html, page- | — | S |
| DNS CAA records | r | ➖ | CAA is a DNS-zone concern, not site code — out of scope of the repo. Not verifiable from the codebase or local http artifacts. | — | S |
| Referrer-Policy | r | ✅ | Referrer-Policy: strict-origin-when-cross-origin served on / (.audit/headers-home.txt) and set globally in next.config.ts headers() (source '/(.*)'). This is ex | — | S |
| Permissions-Policy | r | ✅ | Permissions-Policy: camera=(), microphone=(), geolocation=() served on / (.audit/headers-home.txt) and set globally in next.config.ts. Disables the three high-r | — | S |

### Privacy — ✅5 ⚠️1 ❌0 ➖0

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Privacy policy | R | ✅ | app/politique-confidentialite/page.tsx présente une politique complète et substantielle (pas du boilerplate) : responsable de traitement nommé avec adresse post | — | S |
| Cookie consent | R | ✅ | Bandeau opt-in conforme RGPD via vanilla-cookieconsent. lib/cookieconsent/config.ts:10 mode 'opt-in' (aucun tracking non-essentiel avant acceptation). Boutons à | — | S |
| Global Privacy Control (GPC) | r | ⚠️ | Le site DÉTECTE et honore GPC pour le moteur de consentement : components/cookie-consent/ConsentProvider.tsx:56-80 lit navigator.globalPrivacyControl et, si tru | Ajouter une phrase dans politique-confidentialite/page.tsx déclarant le respect de GPC et son effet. Optionnellement, suppresser aussi OpenPanel sous GPC (passe | S |
| Third-party scripts and privacy | r | ✅ | Surface tierce minimale et maîtrisée. (1) OpenPanel : script op1.js AUTO-HÉBERGÉ depuis l'origine (/op1.js dans public/, layout.tsx:158) plutôt qu'un CDN tiers  | — | M |
| Privacy-respecting analytics | r | ✅ | Stack analytics conforme au pattern privacy-respecting. OpenPanel auto-hébergé (lib/openpanel.ts, instance analytics.elevaseo.app) — endpoint maîtrisé, pas de p | — | S |
| Data minimisation | r | ✅ | Collecte sobre et justifiée. Formulaire de contact (components/contact/contact-form.tsx) : firstName, lastName, email (requis), company (optionnel), phone (opti | — | S |

### Resilience — ✅2 ⚠️0 ❌0 ➖2

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| Custom error pages (404, 500) | R | ✅ | 404 confirme : `curl/fetch http://localhost:3001/cette-url-nexiste-pas` -> HTTP 404 Not Found (pas de soft-404). Body en francais, clair, sans stack trace : `ap | — | S |
| Maintenance pages and 503 | r | ➖ | Aucune page de maintenance ni reponse 503/Retry-After dans le repo : pas de `vercel.json`, pas de middleware/edge rule de maintenance. Les seules occurrences de | — | M |
| Monitoring and uptime | r | ➖ | Hors code applicatif. Aucun indice de monitoring dans le repo : pas de dependance Sentry / web-vitals / @vercel/analytics / @vercel/speed-insights dans package. | — | M |
| Web app manifest | r | ✅ | `app/manifest.ts` (MetadataRoute.Manifest) genere /manifest.webmanifest. Servi en 200 avec `Content-Type: application/manifest+json` (verifie : fetch http://loc | — | S |

### Internationalisation — ✅4 ⚠️0 ❌0 ➖5

| Item | St | V | Preuve | Correctif | Eff |
|------|----|---|--------|-----------|-----|
| lang attribute on inline content | R | ✅ | Document langue cohérente: `<html lang="fr" suppressHydrationWarning>` (app/layout.tsx:131), confirmé dans tous les dumps (.audit/page-*.html → `<html lang="fr" | — | — |
| International URL structure | r | ➖ | Site mono-langue FR: aucune variante de langue/région servie. Pas de pattern ccTLD/sous-domaine/sous-répertoire de locale (pas de `/fr/`, `/en/`). URLs propres  | — | — |
| hreflang for language and regional URLs | r | ➖ | Aucun `hreflang` dans les dumps HTML, le sitemap (.audit/route-sitemap.xml), ni les headers — attendu et correct pour un site mono-langue. hreflang n'a de sens  | — | — |
| Language switcher | r | ➖ | Aucun sélecteur de langue dans components/navbar.tsx ni components/footer.tsx (grep switcher/locale/hreflang/Deutsch/English → aucun résultat). Attendu: avec un | — | — |
| RTL and bidirectional text | r | ➖ | Site exclusivement FR (script latin, LTR). Aucun `dir="rtl"` ni contenu arabe/hébreu/persan/ourdou. `dir` absent des dumps (défaut LTR implicite correct). Aucun | — | — |
| Plural rules and grammatical number | r | ➖ | Aucune librairie i18n multi-langue (ICU MessageFormat, Fluent, gettext) ni `Intl.PluralRules` — attendu pour un site dont tout le copy FR est rédigé en dur dans | — | — |
| Localised page metadata | r | ✅ | Metadata entièrement en FR et cohérente avec la locale du document. metadataBase + title.template '%s \| Acieb Énergie', description FR (app/layout.tsx). `og:lo | — | — |
| Locale-aware content | r | ✅ | Formatage locale FR systématique côté code: `new Intl.NumberFormat("fr-FR", …)` pour montants/euros (lib/comparateur/calculate-comparison.ts:143, lib/simulateur | — | — |
| Avoid automatic IP-based language redirects | A | ✅ | Aucune redirection automatique par IP/géoloc/Accept-Language. proxy.ts ne fait que poser un header Link rel=canonical (aucune logique de locale, aucun NextRespo | — | — |

## Plan d'attaque priorisé

Chaque entrée : `[catégorie] item — correctif — effort`. Cocher au fur et à mesure.

### P0 — `required` en échec (11) — contrat/indexation menacés

- [ ] **[seo] Heading hierarchy** (⚠️) — Sur la page contact, remonter les blocs de section de h3 → h2 (ou insérer un h2 de section englobant), pour obtenir h1→h2→(h3). Préférer un composant Heading avec prop `level` pour figer la hiérarchie. — `S`
- [ ] **[performance] Cache-Control headers** (⚠️) — Optionnel : expliciter Cache-Control HTML (ex. public, max-age=0, must-revalidate ou s-maxage + stale-while-revalidate) plutôt que de laisser le navigateur en heuristique. Confirmer en prod que /_next/static/* renvoie bien public, max-age=31536000, immutable. — `S`
- [ ] **[accessibility] Visible focus indicators** (⚠️) — Add a global :focus-visible rule in globals.css (outline: 3px solid orange.600; outline-offset: 2px) covering a, button, [role=button], input, select, textarea so every focusable element has a consistent, high-contrast ring. — `S`
- [ ] **[accessibility] Semantic HTML and landmarks** (⚠️) — Wrap the navbar's outer element in a <header> (or add role='banner') and ensure the primary <nav> has aria-label='Principale'. Drop the redundant role='main'/role='contentinfo'. — `S`
- [ ] **[accessibility] Accessible data tables** (⚠️) — Add scope='col' to each <thead> <th>, scope='row' to leading row-header cells, and a <caption> (or aria-label on a wrapping region) naming each table. Wrap wide tables in <div role='region' aria-label tabindex='0' style='overflow-x:auto'> for keyboard scroll. — `S`
- [ ] **[security] Clickjacking protection (frame-ancestors / X-Frame-Options)** (⚠️) — Add `Content-Security-Policy: frame-ancestors 'none'` (ideally as part of a full CSP) alongside the existing X-Frame-Options: DENY in next.config.ts headers(). — `S`
- [ ] **[performance] Image optimisation** (⚠️) — Recompresser/redimensionner l'image hero du guide (vérifier sizes/quality next/image). Pour la home, fournir un poster image optimisé à la <video> et/ou réduire le poids de la vidéo (compression, format AV1/WebM). — `M`
- [ ] **[accessibility] Colour contrast** (❌) — Use orange-800 for orange text on white (globals.css already does this for .prose links via --tw-prose-links: orange.800 — extend the same token to UI eyebrows, list links and CTA-adjacent text). Darken breadcrumb/label text from gray-400 to gray-600 (#4b5563, 7:1). For the /20 decorative numbers, either treat them as truly decorative (they are large display digits; if purely ornamental they are arguably exempt) or bump opacity so they clear 3:1. — `M`
- [ ] **[accessibility] Accessible form errors** (⚠️) — Add aria-invalid={!!state.errors?.field} to each input; give every error <p> an id and point the field's aria-describedby at it; wrap the success/error toast in role='alert' (or aria-live='assertive') so screen readers announce submission result. — `M`
- [ ] **[accessibility] Reduced motion** (❌) — Add a global @media (prefers-reduced-motion: reduce) block in globals.css zeroing animation/transition durations; gate the hero <video> autoplay behind matchMedia('(prefers-reduced-motion: no-preference)') (or ship the poster as a static fallback); use framer-motion's useReducedMotion() to skip entrance animations. — `M`
- [ ] **[performance] Core Web Vitals (LCP, INP, CLS)** (❌) — 1) Home : donner un poster optimisé (AVIF/WebP) au <video> et le preloader (<link rel=preload as=image fetchpriority=high>) car c'est l'élément LCP ; envisager une image hero statique au-dessus de la ligne de flottaison plutôt qu'une vidéo lourde. 2) Guide : ajouter priority/fetchpriority=high sur l'<Image> hero (next/image prop priority). 3) Outil : corriger le CLS (voir slug core-web-vitals/lazy-loading) en réservant la hauteur du wizard. 4) Réduire le render-blocking CSS (~450ms) — voir critical-css. — `L`

### P1 — `recommended` en échec (26)

- [ ] **[foundations] <meta name="theme-color">** (⚠️) — Add `themeColor` to Next viewport export in app/layout.tsx (e.g. `export const viewport = { themeColor: [{ media: '(prefers-color-scheme: light)', color: '#ffffff' }, { media: '(prefers-color-scheme: dark)', color: '#0b1020' }] }`). — `S`
- [ ] **[foundations] <meta name="color-scheme">** (❌) — Add `colorScheme: 'light'` (or `'light dark'` if dark mode is audited) to the Next viewport export in app/layout.tsx, and set `:root { color-scheme: light; }` in app/globals.css. — `S`
- [ ] **[foundations] Feed content hygiene** (⚠️) — Add xmlns:sy + `<sy:updatePeriod>weekly</sy:updatePeriod><sy:updateFrequency>1</sy:updateFrequency>` to the channel in app/feed.xml/route.ts; optionally derive lastBuildDate from the newest post date. — `S`
- [ ] **[agent-readiness] HTTP Link headers for discovery** (❌) — Add a Link header to next.config.ts headers() (source '/' or '/(.*)') advertising IANA-registered relations: Link: </llms.txt>; rel="describedby"; type="text/markdown"; title="Site index for LLMs", </sitemap.xml>; rel="sitemap"; type="application/xml", </feed.xml>; rel="alternate"; type="application/rss+xml". Use root-relative URLs, comma-separated, no quotes around the URIs (angle brackets only). Keep consistent with the <link rel> entries in <head>. — `S`
- [ ] **[performance] Lazy loading images, iframes, and video** (⚠️) — Donner au fallback Suspense une min-height proche de la hauteur réelle du wizard (ou un skeleton dimensionné) dans app/outils/comparateur-electricite-pro/page.tsx:152. Ajouter un poster à la <video> hero. — `S`
- [ ] **[performance] Preload, prefetch, preconnect** (⚠️) — Ajouter priority sur l'<Image> hero du guide (next/image émet preload+fetchpriority=high). Pour la home, <link rel=preload as=image fetchpriority=high> du poster vidéo. — `S`
- [ ] **[performance] No-Vary-Search response header** (❌) — Ajouter dans next.config.ts headers() (source /(.*)) un header No-Vary-Search: params=("utm_source" "utm_medium" "utm_campaign" "utm_content" "utm_term" "gclid" "fbclid" "msclkid"), key-order. Idéalement au niveau CDN/edge. — `S`
- [ ] **[performance] Resource hints overview** (⚠️) — Ajouter le preload/fetchpriority=high de l'élément LCP (voir slug preload-prefetch-preconnect). Pas d'autre origine à preconnect. — `S`
- [ ] **[performance] View Transitions** (❌) — Optionnel : ajouter @view-transition { navigation: auto; } dans globals.css + une règle prefers-reduced-motion: reduce qui désactive l'animation. Faible coût, gain UX sur navigations. — `S`
- [ ] **[performance] Scrollbar gutter** (❌) — Ajouter dans globals.css : :root { scrollbar-gutter: stable; }. Intervention d'une ligne, gain CLS/UX direct. — `S`
- [ ] **[accessibility] Skip links** (❌) — Add a visually-hidden-until-focus <a class='skip-link' href='#main'>Aller au contenu</a> as the first child of <body> in app/layout.tsx, give the shared layout <main id='main' tabindex='-1'>, and add the .skip-link offscreen/:focus CSS to globals.css. — `S`
- [ ] **[security] /.well-known/security.txt** (❌) — Create public/.well-known/security.txt (served as text/plain) or app/.well-known/security.txt/route.ts with RFC 9116 fields: Contact: mailto:security@aciebenergie.fr (or bastien@elevaseo.com), Expires (~1yr ISO 8601, e.g. 2027-06-01T00:00:00Z), Canonical: https://www.aciebenergie.fr/.well-known/security.txt, Preferred-Languages: fr, en. — `S`
- [ ] **[privacy] Global Privacy Control (GPC)** (⚠️) — Ajouter une phrase dans politique-confidentialite/page.tsx déclarant le respect de GPC et son effet. Optionnellement, suppresser aussi OpenPanel sous GPC (passer disabled/skip via la valeur GPC lue) pour un honneur strict. — `S`
- [ ] **[foundations] Canonical URL (rel="canonical")** (⚠️) — Add `alternates: { canonical: '/contact' }` to app/contact/page.tsx metadata; better, centralise self-referencing canonicals via metadataBase + a shared helper / generateMetadata so every route gets one by default. Audit all 147 URLs for missing canonical. — `M`
- [ ] **[foundations] Favicons and app icons** (⚠️) — Add app/favicon.ico (multi-res 16/32/48) or public/favicon.ico, optionally an SVG favicon, and a 512x512 maskable icon entry in app/manifest.ts. — `M`
- [ ] **[seo] Breadcrumbs** (⚠️) — Étendre le fil d'Ariane (visible <nav aria-label="breadcrumb"> + BreadcrumbList JSON-LD synchronisé) à toutes les pages de niveau ≥2 : secteurs/*, solutions/*, expertise/*, guides/<article>, reglementation/*, contact. Dériver le trail de l'URL. Uniformiser l'aria-label. — `M`
- [ ] **[agent-readiness] Per-page Markdown source endpoints** (⚠️) — Add a catch-all .md route handler (e.g. app/[category]/[slug]/[...] or a /guides/[slug].md style route) that serves the raw MDX body of each content-collections entry with frontmatter (title, url, updated, sources), Content-Type text/markdown; charset=utf-8, Cache-Control, and X-Markdown-Tokens. Inject <link rel="alternate" type="text/markdown" href=".../slug.md"> in the article <head> and reference the pattern near the top of llms.txt. — `M`
- [ ] **[agent-readiness] Agent Skills discovery** (❌) — If you want to ship it: author a SKILL.md (YAML frontmatter name+description) teaching agents to use /services.md, /llms.txt and the .md endpoints; host at /.well-known/agent-skills/acieb/SKILL.md (text/markdown, CORS *); publish /.well-known/agent-skills/index.json with $schema=https://schemas.agentskills.io/discovery/0.2.0/schema.json and a skills array carrying name/type/description/url/sha256 digest; advertise via Link rel="agent-skills". Otherwise mark as a deliberate non-goal (draft standard). — `M`
- [ ] **[well-known] Well-known URIs** (❌) — Establish a /.well-known/ presence in Next.js App Router. Since the framework wraps file responses, serve well-known resources via route handlers under app/.well-known/<name>/route.ts (not public/, to control Content-Type and avoid octet-stream). Start with the two registered names already probed: (1) app/.well-known/api-catalog/route.ts (see api-catalog finding), (2) a security.txt (security category — out of scope here but same mechanism). Use exact registered IANA names only, serve over HTTPS with HTTP 200 and correct Content-Type, and keep the middleware/headers pass-through (current next.config headers() are global and harmless to /.well-known/). — `M`
- [ ] **[well-known] /.well-known/api-catalog** (❌) — Create app/.well-known/api-catalog/route.ts returning a valid Linkset (RFC 9264) with Content-Type: application/linkset+json; charset=utf-8 and HTTP 200. Anchor on the site root and key arrays by IANA link relations, e.g. describedby -> /llms.txt (text/markdown) and /services.md, alternate/related -> /feed.xml (application/rss+xml), sitemap -> /sitemap.xml (application/xml). Build hrefs from siteConfig.url (used in app/sitemap.ts and app/robots.ts) so they stay absolute and same-origin. Then advertise it by adding `Link: </.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json"` to next.config.ts headers() on source "/(.*)". Validate the JSON parses and every referenced URL returns 200 from the same origin. — `M`
- [ ] **[performance] Critical CSS and render-blocking resources** (❌) — Difficile à corriger entièrement avec l'App Router Next (gestion CSS interne). Pistes : réduire la taille du bundle CSS principal (0q4uwe = 135KB décompressé), code-splitting CSS par route, ou inlining critical via un outil de build. Au minimum, surveiller la croissance du CSS global. — `M`
- [ ] **[performance] Script loading — defer, async, module** (⚠️) — Réduire le JS inutilisé (code-splitting, vérifier optimizePackageImports déjà partiel sur framer-motion/three). Ajuster la cible browserslist pour supprimer les polyfills legacy (Array.prototype.at). Auditer la nécessité d'embed.js. — `M`
- [ ] **[performance] Speculation Rules** (❌) — Optionnel : injecter un <script type=speculationrules> (prerender moderate sur liens HTML same-origin, prefetch conservative en fallback) via le layout, en excluant les routes à effet de bord. À combiner avec No-Vary-Search. — `M`
- [ ] **[performance] Visibility-aware rendering** (❌) — Optionnel : appliquer content-visibility: auto + contain-intrinsic-size sur les sections longues répétées (ex. les blocs H2 de l'outil/guide). Bien dimensionner contain-intrinsic-size pour ne pas réintroduire de CLS. — `M`
- [ ] **[accessibility] CSS state and relational selectors** (⚠️) — Optionally layer :user-invalid/:user-valid for client-side field feedback and form:has(:user-invalid) to dim the submit button, complementing (not replacing) the server-side error messages. — `M`
- [ ] **[security] Content Security Policy (CSP)** (❌) — Add a nonce-based CSP in next.config.ts (or middleware for per-request nonces): default-src 'self'; script-src 'self' 'nonce-{r}' 'strict-dynamic' plus the self-hosted OpenPanel origin and Google Analytics (www.googletagmanager.com) in connect-src/script-src; object-src 'none'; base-uri 'none'; frame-ancestors 'none'. Ship as Content-Security-Policy-Report-Only first to catch breakage from GA/OpenPanel/three.js/framer-motion inline styles, then enforce. — `L`

### P2 — `avoid` présents à retirer (0)

_Aucun anti-pattern `avoid` détecté._


---

_Généré depuis `.audit/findings-*.json` (10 sous-agents) + `.audit/checklist-master.json`. Spec : `docs/superpowers/specs/2026-06-01-seo-ai-spec-audit-design.md`. Artefacts runtime jetables dans `.audit/` (gitignored)._