{
  "summary": "Adopt next/image via a WpImage wrapper across blocks + migrate fonts to next/font, preserving pixel parity",
  "agentCount": 14,
  "logs": [
    "Migrate: 36 WP-image files in 9 groups"
  ],
  "result": {
    "foundation": "All edits complete. Here is the report for the migration agents.\n\n## Files changed\n1. `/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/ui/WpImage.tsx` (NEW)\n2. `/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/ui/ArtDirectedImage.tsx` (NEW)\n3. `/opt/projects/.covalba-wp-worktrees/tom-passage-wp/next.config.ts` (images: deviceSizes, imageSizes, qualities, env-driven remotePattern)\n4. `/opt/projects/.covalba-wp-worktrees/tom-passage-wp/app/layout.tsx` (next/font for Jakarta + Grotesk; Satoshi kept on Fontshare + preconnect)\n5. `/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/index.css` (body + .font-body now `var(--font-body), 'Plus Jakarta Sans', sans-serif`)\n6. `/opt/projects/.covalba-wp-worktrees/tom-passage-wp/tailwind.config.ts` (body/display fontFamily prepend CSS vars)\n\n## WpImage — public API\n- Default export: `import WpImage from \"@/components/ui/WpImage\";`\n- Props:\n  - `image: WpImage | null | undefined` — direct `mapImage(...)` output (type `WpImage` from `@/lib/wp/types`)\n  - `fill?: boolean` (default `false`)\n  - `priority?: boolean` (default `false`)\n  - `sizes?: string`\n  - `className?: string`\n  - `alt?: string` (overrides `image.altText`)\n  - `fetchPriority?: \"high\" | \"low\" | \"auto\"`\n- Behavior: returns `null` if `!image?.sourceUrl`. In intrinsic mode requires both `width` and `height` non-null; if either is null it auto-falls-back to `fill` (with a dev `console.warn`) — so a null-dim image MUST be wrapped in a positioned/sized container. `className` lands on the rendered `<img>` (object-cover / rounded / sizing preserved). `sizes` defaults to `\"100vw\"` only in fill mode; intrinsic mode leaves `sizes` undefined.\n\n## ArtDirectedImage — public API\n- Default export: `import ArtDirectedImage from \"@/components/ui/ArtDirectedImage\";`\n- Props:\n  - `mobile: WpImage | null | undefined` — `mapImage(...)` output (below breakpoint)\n  - `desktop: WpImage | null | undefined` — `mapImage(...)` output (at/above breakpoint)\n  - `alt: string` (required, shared)\n  - `priority?: boolean` (default `false`, applied to BOTH images)\n  - `breakpoint?: \"sm\" | \"md\" | \"lg\"` (default `\"md\"` = the original `max-width:767px`)\n  - `className?: string` — wrapper `<div>` class; MUST position/size it (e.g. `absolute inset-0 -z-10`) since both inner images use `fill`\n  - `sizes?: string` (default `\"100vw\"`)\n  - `imgClassName?: string` — class for each `<Image>` (e.g. `object-cover`, `scale-[1.02]`)\n- Behavior: renders a wrapper `<div className={className}>` containing two `<Image fill>`. Mobile image gets `alt`; desktop gets `alt=\"\" aria-hidden` to avoid double SR announcement. Returns `null` only if both sources are null; if only one is present it is used for both breakpoints. Uses `cn` from `@/lib/utils`.\n\n## Usage notes for migration agents\n- Raw `<img src={image.sourceUrl} alt={image.altText} className=... />` → `<WpImage image={image} className=... priority={props.position === 0} />`. The block-level `if (!image) return null` guards may stay (they short-circuit the whole section) or be dropped where the image is optional, since WpImage null-guards internally.\n- `priority`/LCP convention: pass `priority={position === 0}` for above-the-fold heroes (the `position` prop is the 0-based section index passed by BlockRenderer at lines 98 & 116). Leave `priority={false}` everywhere else so next/image lazy-loads.\n- For `<picture>` migrations (LogistiqueHero, DistributionHero, LogistiqueSolution, DevenirApplicateur, PrimeCEECoolRoof, QuiSommesNous): replace with `<ArtDirectedImage>`. The original `<picture className=\"absolute inset-0 -z-10\">` class goes to `className`; the `<img className=\"size-full object-cover scale-[1.02]\">` classes go to `imgClassName`. Default `breakpoint=\"md\"` exactly reproduces the original `media=\"(max-width: 767px)\"`. These call sites currently use LOCAL `/images/*.jpg` assets, not WP media — to pass them through WpImage/ArtDirectedImage you must shape them as `WpImage` objects `{ sourceUrl, altText, width, height }`. Local `/images/*` paths are NOT covered by `remotePatterns` but next/image serves local-path images without remotePattern matching, so a `{ sourceUrl: \"/images/...jpg\", altText: \"\", width: null, height: null }` shape works (fill mode auto-selected). If using true WP media for art direction, both hosts are whitelisted.\n- `quality`: project default is next/image's built-in (omit prop). Whitelisted custom values are `70, 80, 90` — hero/LCP may pass `quality={90}`, dense thumbnails `quality={70}`. Any other value will throw at runtime under Next 16.\n- Component name collision note: the COMPONENT is `WpImage` (default export from `@/components/ui/WpImage`); the TYPE is `WpImage` from `@/lib/wp/types` (imported inside the components aliased as `WpImageData`). When importing both in a block, alias one.\n- Fonts: `--font-body` (Plus Jakarta Sans) and `--font-display` (Space Grotesk) now come from next/font and are injected on `<html>`. `font-body` / `font-display` Tailwind utilities and the CSS rules resolve to the var first, literal family as fallback — no visual change. Satoshi (`'Satoshi'`, `.font-satoshi`, `h1-h6`, Tailwind `satoshi`) is UNCHANGED, still served by the Fontshare `<link>` (now with preconnect). Weights preserved exactly: Jakarta 300/400/500/600/700, Grotesk 700, Satoshi 400/500/700.\n\n## Flags for Tom (decisions, not done)\n- Satoshi `font-black` (900) is used in components but only 400/500/700 are loaded today (faux-bold synthesized). Migration preserved this exactly — changing it is a design decision, not part of this perf work.\n- Optional follow-up: self-host Satoshi 400/500/700 woff2 in `public/fonts/` then migrate to `next/font/local` with `variable:\"--font-satoshi\"`; that would remove the last render-blocking external stylesheet. Requires adding binary assets — out of scope here.",
    "migratedGroups": [
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/AvantApres.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/Certifications.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/Citation.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/CompatibiliteSupports.tsx"
        ],
        "parityNotes": "All 8 raw <img> swapped for <WpImage> with identical className strings, alt logic, and DOM. No restyling. Added `import WpImage from \"@/components/ui/WpImage\"` to each file; all `mapImage` imports remain in use so nothing was removed. `loading=\"lazy\"` dropped everywhere (next/image lazy-loads by default; no `priority` set since none of these are the position===0 hero — they are mid-page blocks, so default lazy behavior is preserved).\n\nAvantApres.tsx (wp-fill, 2 imgs): both avant/apres images are `w-full h-full object-cover rounded-[2rem]` inside `.relative ... aspect-square overflow-hidden` parents → `fill`. className kept verbatim (object-cover + rounded-[2rem]). alt fallback chain (`altText || legende || \"Avant\"/\"Après\"`) preserved by passing it as the `alt` prop, which overrides image.altText exactly as the original `||` chain did. Parents were already `relative`, no structural change.\n\nCertifications.tsx (wp-intrinsic, 1 img): logo `h-14 w-auto object-contain mb-4`, not full-bleed → intrinsic mode (no `fill`). WpImage uses width/height from mapImage; if WP mediaDetails are null it auto-falls back to fill with a dev warn — acceptable per the wrapper spec, and the logo container is unpositioned which matches the original inline-flow layout. className kept verbatim.\n\nCitation.tsx (wp-fill, 1 img): avatar `w-14 h-14 rounded-full object-cover`. Original was a bare <img> with no wrapper. Per the spec note (\"better as fill in a relative w-14 h-14 wrapper\"), I wrapped it in a NEW `<div className=\"relative w-14 h-14 rounded-full overflow-hidden\">` and put `fill` on WpImage with `className=\"w-full h-full rounded-full object-cover\"` + `sizes=\"56px\"`. This is the one place a wrapper div was added (fill mode mandates a positioned/sized parent). Visual result is identical: 56x56 circular cropped avatar. The rounded-full + overflow-hidden on the wrapper guarantees the circular clip even though the inner fill img is absolutely positioned.\n\nCompatibiliteSupports.tsx (wp-fill, 4 imgs): all `w-full h-full object-cover` inside aspect/fixed boxes → `fill`. Three parents were already `relative` (mobile strip thumb w-[72px], desktop tab thumb w-14, active panel aspect-[4/3]) — left untouched. The grid-render thumbnail box (`aspect-[4/3] rounded-lg overflow-hidden bg-foreground/5 mb-4`) was the only one NOT positioned, so I added `relative` to it (required for next/image fill; the box already had overflow-hidden so no visual change). Added explicit `sizes` to the two small fixed-px thumbnails (72px, 56px) to avoid oversized fill downloads; the two aspect-ratio boxes use the wrapper's default sizes=\"100vw\" (fill mode). className kept verbatim on all four.",
        "skipped": [],
        "uncertainties": "1) Citation avatar: chose `fill` (object-cover crop) per the note's preference over intrinsic 56x56. Required adding one wrapper div — the only DOM addition in this group. If strict \"no new DOM nodes\" parity is required, the alternative is intrinsic mode with `className=\"w-14 h-14 rounded-full object-cover\"` and no wrapper, but that needs non-null WP dims and would not crop non-square sources the same way object-cover does. Current approach preserves the visual crop exactly.\n2) Added `relative` to the grid-render box in CompatibiliteSupports — this is a class addition (not a swap). It is invisible (box already clips via overflow-hidden) and is mandatory for next/image fill. Flagging since the instruction said \"do not restyle\"; `relative` is a positioning prerequisite, not a style change.\n3) Added explicit `sizes` (56px/72px) on the small fixed thumbnails and the Citation avatar. The wrapper defaults to \"100vw\" in fill mode, which would over-request bytes for a 56px box; the explicit sizes is a perf-correctness choice that does not affect layout/pixels. The two larger aspect-ratio boxes were left at the default to stay conservative.\n4) `priority`: none set. The BlockRenderer position===0 hero convention does not apply to any of these four blocks in their typical placement (mid-page before/after, certifications, quote, compatibility). If any of these can legitimately render as the first above-the-fold section, a follow-up could thread `priority={props.position === 0}` (the `position` prop is already on each component's props type)."
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/Etapes.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/GrilleCards.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/Hero.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/Logos.tsx"
        ],
        "parityNotes": "All four files migrated from raw <img> to the WpImage wrapper. No <picture>/ArtDirectedImage cases in this group. No inline/external SVGs were touched. mapImage import remains used in every file; no imports became unused; next/image is not imported directly anywhere.\n\nEtapes.tsx (wp-intrinsic): <img className=\"w-full object-cover\" loading=\"lazy\"> -> <WpImage image={image} alt={image.altText || etape.titre || \"\"} className=\"w-full object-cover\" />. INTRINSIC mode (no fill). Exact className preserved. loading=\"lazy\" dropped because next/image lazy-loads by default when priority is false (default). Wrapping <div className=\"mt-4 max-w-md rounded-xl overflow-hidden\"> and DOM structure unchanged.\n\nGrilleCards.tsx (wp-fill): <img className=\"w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105\" loading=\"lazy\"> -> <WpImage image={image} fill ...>. Exact className preserved (w-full h-full are harmless/no-op under fill which sets absolute inset-0 100%). Added `relative` to the media box `div` (was `aspect-[16/10] overflow-hidden rounded-xl mb-5`) because next/image fill requires a positioned parent; `relative` is positioning-only and produces no pixel change since the box already sized+clipped the in-flow img. Added sizes=\"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw\" reflecting the responsive grid (1/2/3 cols). loading=\"lazy\" dropped (default lazy).\n\nHero.tsx (wp-fill, priority): <img className=\"w-full h-full object-cover scale-[1.02]\" loading=\"eager\"> -> <WpImage image={image} fill priority={props.position === 0} sizes=\"100vw\" className=\"w-full h-full object-cover scale-[1.02]\" />. Parent was already `absolute inset-0` (positioned) so fill works without adding `relative`. loading=\"eager\" maps to priority for the above-the-fold hero via position===0 (BlockRenderer convention). Exact className and surrounding gradient overlays / DOM unchanged.\n\nLogos.tsx (wp-intrinsic): <img className=\"h-8 lg:h-10 w-auto opacity-50 hover:opacity-100 transition-opacity grayscale hover:grayscale-0\" loading=\"lazy\"> -> <WpImage image={logo.image} alt={logo.image!.altText || logo.nom} className=\"...\" />. INTRINSIC mode. Passed image={logo.image} (the wrapper null-guards internally; the surrounding .filter((l) => l.image) already guarantees non-null, and alt still uses logo.image!.altText which is safe under that filter). Exact className preserved (h-8/h-10 w-auto fixed-height/auto-width). loading=\"lazy\" dropped (default lazy). The marquee duplication [...customLogos, ...customLogos] is untouched so the visual 2x is preserved.",
        "skipped": [],
        "uncertainties": "1) Logos.tsx INTRINSIC dependency on dimensions: WpImage auto-falls-back to fill when width/height are null (mediaDetails missing). In the marquee these logos sit in an in-flow `h-8 w-auto` container with NO positioned parent, so a fill fallback would collapse/overflow. If WP logo media lacks mediaDetails this would break layout — confirm logo media items carry width/height, or the marquee item div may need `relative` as a safety net. Kept as intrinsic per the wp-intrinsic category.\n2) Logos SVG note: the task says leave SVG logos as-is, but these come through mapImage (WP media) rendered via raw <img>, indistinguishable at compile time. I migrated them as WP intrinsic images per the assigned category. If actual logo assets are SVG uploaded to WP, next/image will still serve them (SVG via next/image requires `dangerouslyAllowSVG` in next.config; not verified here). Flag for Tom if WP logos are SVG.\n3) Quality prop: removed an initial quality={90} on the Hero because the documented WpImage API does not expose/forward a `quality` prop (component only forwards image/fill/priority/sizes/className/alt/fetchPriority). Hero LCP therefore uses next/image default quality. If quality={90} on the LCP hero is desired, the WpImage wrapper would need to add a forwarded quality prop (90 is whitelisted in next.config).\n4) GrilleCards sizes string is my best estimate from the colsClass breakpoints (default 3-col); other colonnes values (2/4) would have slightly different ideal sizes, but sizes only affects which srcset candidate is picked, not layout/pixels."
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/ReferencesGrille.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/TemoignagesVideo.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/TexteImage.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/Timeline.tsx"
        ],
        "parityNotes": "All four files migrated to the WpImage wrapper. No <picture>/ArtDirectedImage cases existed in this group.\n\nReferencesGrille.tsx (wp-fill): swapped the raw <img> for <WpImage fill>. Source is a plain data string (reference.image), not mapImage() output, so I shaped it inline as a WpImage object { sourceUrl: reference.image ?? \"/placeholder.svg\", altText: reference.title, width: null, height: null } — the /placeholder.svg fallback is preserved verbatim. className \"h-full w-full object-cover transition-transform duration-700 group-hover:scale-105\" forwarded unchanged. Direct parent already \"relative aspect-[16/10] overflow-hidden bg-navy\" (positioned + sized) so fill is correct and pixel-identical. Added alt={reference.title} override. loading=\"lazy\" dropped (next/image lazy-loads by default; priority left false → not above the fold). Added `import WpImage from \"@/components/ui/WpImage\"`.\n\nTemoignagesVideo.tsx (wp-mixed): img#1 (decorative /images/applicateur-blobs.svg with CSS radial mask, aria-hidden) LEFT AS RAW <img> per rules (external/decorative SVG, do not convert). img#2 (YouTube thumbnail https://img.youtube.com/vi/{id}/hqdefault.jpg) swapped to <WpImage fill>, shaped inline as a WpImage object since the URL is computed, not mapImage() output. Same alt string reused for both image.altText and the alt override. className \"w-full h-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.32,0.72,0,1)] group-hover:scale-[1.05]\" forwarded unchanged. Direct parent is the <a> with \"relative ... aspect-[16/11] sm:aspect-[4/5] rounded-2xl overflow-hidden\" (positioned + sized) so fill is correct. The {videoId && ...} guard preserved exactly. Requires img.youtube.com in next.config remotePatterns (flagged in uncertainties). Added WpImage import.\n\nTexteImage.tsx (wp-fill): swapped <img src={image.sourceUrl} alt={image.altText} className=\"w-full h-full object-cover rounded-[2rem]\"> for <WpImage image={image} fill className=\"w-full h-full object-cover rounded-[2rem]\"> — image passed directly as mapImage() output. The block-level `image &&` guard kept (it wraps the whole positioned glow wrapper, not just the img). Added `relative` to the direct parent div (\"rounded-[2rem] overflow-hidden shadow-...\") because fill requires a positioned container and that div had no positioning; this is a visual no-op (no offsets, no absolutely-positioned children) and preserves rounded corners + overflow clip. priority left false (TexteImage is a content section, not the position===0 hero; the position prop is unused here). Added WpImage import.\n\nTimeline.tsx (wp-intrinsic): swapped <img> for <WpImage> in INTRINSIC mode (no fill prop) — small constrained logo with max-h-28 max-w-28 object-contain centered in a flex box (not full-bleed). image passed directly as mapImage() output; alt override = image.altText || jalon.titre || \"\" preserved verbatim. className \"max-h-28 max-w-28 object-contain transition-transform duration-500 group-hover:scale-105\" forwarded unchanged. loading=\"lazy\" + decoding=\"async\" dropped (next/image defaults). The `image &&` guard kept (controls the grid-cols layout class). Added WpImage import.\n\nNo now-unused img-related code left behind; no leftover loading/decoding attributes; the only remaining raw <img> across the four files is the intentionally-kept decorative SVG in TemoignagesVideo.",
        "uncertainties": "1. TexteImage fill + self-sizing height: the original <img> had \"w-full h-full\" inside a parent (\"rounded-[2rem] overflow-hidden\") with NO explicit height, so the box height was driven by the image's natural aspect ratio. With next/image `fill` (absolute, inset-0), the container needs an intrinsic height or it collapses to 0. The spec tagged this wp-fill and the parent has no aspect ratio, so this is the one spot where fill could change layout vs the original self-sizing behavior. If a height regression appears, the parity-safe alternative is intrinsic mode (drop the `fill` prop) so the WP width/height drive the box exactly as before — recommend a visual check on this block. I followed the spec's wp-fill categorization and added `relative` to make fill valid.\n\n2. TemoignagesVideo requires img.youtube.com whitelisted in next.config.ts images.remotePatterns. The API report says next.config.ts was updated with an env-driven remotePattern for WP media; img.youtube.com is an EXTERNAL host and may not be covered. Confirm a remotePattern for hostname \"img.youtube.com\" exists or next/image will throw at runtime. (Did not run build/dev per instructions, so this is unverified.)\n\n3. ReferencesGrille and TemoignagesVideo pass width:null/height:null shaped objects → WpImage forces fill mode (correct, both have positioned/sized parents). The dev-only console.warn for missing dims will NOT fire because `fill` is passed explicitly.\n\n4. Did not run build or typecheck per instructions (\"Do NOT run build/dev\").",
        "skipped": [
          "TemoignagesVideo.tsx line 18 <img src=/images/applicateur-blobs.svg> — intentionally kept as raw <img> per rules (decorative external SVG with CSS radial-gradient mask, aria-hidden; not a WP/mapImage source)"
        ]
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/Hero.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/LocalHero.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/Navbar.tsx"
        ],
        "skipped": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/SectorReferenceHighlights.tsx"
        ],
        "parityNotes": "Hero.tsx: img#1 (full-bleed hero, import heroImage '@/assets/hero-coolroof.webp' via imageSrc prop, absolute inset-0 parent, w-full h-full object-cover scale-[1.02], loading=eager) -> WpImage fill + priority. Shaped the prop string into WpImageData { sourceUrl: imageSrc, altText: imageAlt, width: null, height: null } (local asset, no intrinsic dims -> fill is correct and the parent <div class=\"absolute inset-0\"> is already positioned). Original className preserved verbatim; loading=eager is now expressed via priority (eager + fetchpriority high), which is the correct above-the-fold mapping. The two /logos/*.svg client-logo <img> (object-contain brightness-0 invert, the mobile grid line ~97 and the marquee line ~216) and the inline <svg> chevron were left untouched per the external/public-SVG rule. heroImage import is still used (default value for imageSrc prop), kept.\n\nLocalHero.tsx: img#1 (full-bleed hero, heroImage.src, absolute inset-0 parent, w-full h-full object-cover scale-[1.02], loading=eager) -> WpImage fill. Shaped into WpImageData with width/height null (local asset). No priority added: this component is rendered directly (not via BlockRenderer position===0), and the notes specified only fill for it. className preserved verbatim. The /logos/*.svg marquee logo <img> (line ~91) kept inline per rule.\n\nNavbar.tsx (SHARED FILE per CLAUDE.md): img#2 (product image p.img = import @/assets/produits/*.webp via .src, inside a flex box, h-24 w-32 object-contain object-bottom drop-shadow-md group-hover:scale-105, NOT full-bleed) -> WpImage intrinsic. The products array stored only .src as a string, so intrinsic mode would have null dims and silently fall back to fill (which breaks the fixed h-24 w-32 box). Fixed by adding imgWidth/imgHeight from the static webp imports (StaticImageData.width/.height are typed number, assignable to WpImage number|null). Measured intrinsic dims: CovaTherm 523x477, CovaSeal 515x485, CovaMetal 517x483, CovaTherm-Light 505x494. The p.img truthiness guard and the 'Bientôt' placeholder fallback are intact; original className preserved verbatim. img#1 brand logo (/logo-covalba.svg, line ~134, conditional brightness-0 invert) kept inline per the public-SVG rule.",
        "uncertainties": "SectorReferenceHighlights.tsx was SKIPPED deliberately (not a no-op). reference.image is an arbitrary string from src/data/references.ts, not mapImage() output. Of the reference images, 53 use external hosts NOT whitelisted in next.config.ts remotePatterns (img.youtube.com YouTube thumbnails + 5524202.fs1.hubspotusercontent-na1.net Hubspot), and only 1 uses a local /images/* path. next.config.ts has no `unoptimized` flag and no custom loader, so wrapping these in WpImage would make next/image THROW at runtime for ~all reference cards — a hard parity/correctness break, not a degradation. The fill-shape escape blessed in the wrapper API note only covers local /images/* and the two WP media hosts, never arbitrary third-party hosts. Unblocking requires adding img.youtube.com and the hubspot host to remotePatterns in next.config.ts, which is owned by the wrapper-infra agent (per the API report) and a shared config file under the CLAUDE.md anti-conflict rule, so I did not touch it. Recommendation for Tom: either (a) add those two hostnames to remotePatterns and re-run this single file, or (b) leave SectorReferenceHighlights on raw <img> since its sources are not WP media at all. Also note LocalHero.tsx is a Server Component (no 'use client'); WpImage/next-image work fine there."
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/SectorsGrid.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/SystemSection.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/WinterObjectionSection.tsx"
        ],
        "skipped": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/Testimonials.tsx (img#1 applicateur-blobs.svg = decorative SVG, left inline per rule; img#2 YouTube thumbnail src=https://img.youtube.com/vi/${videoId}/hqdefault.jpg = NOT a WP/local image AND img.youtube.com is NOT in next.config.ts remotePatterns -> converting to WpImage/next/image would throw at runtime, breaking render. Left as raw <img> to preserve parity. No WpImage import added.)",
          "SectorsGrid.tsx img#2 /images/applicateur-blobs.svg (decorative external SVG, left inline per rule)",
          "WinterObjectionSection.tsx img#1 & img#2 /images/applicateur-blobs.svg (decorative external SVGs, left inline per rule)"
        ],
        "parityNotes": "All conversions are drop-in: original className strings copied verbatim onto WpImage (object-cover, rounded, w-full/w-9 h-9, transition/hover scale, gradients of surrounding divs untouched). DOM structure unchanged - WpImage renders a single <img> via next/image in the same parent. loading=\"lazy\" attributes removed because next/image lazy-loads by default (equivalent behavior); no priority anywhere since none of these are BlockRenderer position===0 heroes.\n\nSectorsGrid img#1 (s.img public raster, absolute inset-0 object-cover in positioned <a>): WpImage fill, shaped as {sourceUrl:s.img, altText:s.name, width:null, height:null}.\n\nSystemSection: imageSrc is a string prop, so I added imageWidth/imageHeight props defaulting to coolRoofLayers.width/height (asset is 2048x2048) so intrinsic mode keeps the exact square aspect under className=\"w-full\" (CSS width:100%, height:auto preserves ratio) - identical to native <img className=\"w-full\">. Mobile (img#1) and desktop (img#3) -> WpImage intrinsic with those dims. Avatar (img#2 /maxime-bourassin.jpg) -> WpImage intrinsic width=36 height=36 (rendered box = w-9 h-9 = 36px; object-cover crops the 400x224 source into the square, matching original exactly). Avatar is a flex child with no positioned parent, so intrinsic (not fill) is the correct/safe choice.\n\nWinterObjection img#3 (imageSrc, w-full h-full object-cover inside relative aspect-[4/3] box): WpImage fill, {sourceUrl:imageSrc, width:null, height:null} - the parent div is already relative+sized.\n\nTypeScript: npx tsc --noEmit passes with no errors in any of the 4 files. Build/dev NOT run per instructions.",
        "uncertainties": "1) Testimonials YouTube thumbnail (img.youtube.com) is the only ambiguous case. The migration rules cover \"WP images (mapImage/sourceUrl)\" and the API notes say local /images/* work in fill mode, but a non-whitelisted external host (img.youtube.com) would make next/image throw at runtime. I chose to SKIP it rather than break rendering or modify shared next.config.ts (out of this migration group). If Tom wants it on next/image, add {protocol:\"https\", hostname:\"img.youtube.com\", pathname:\"/vi/**\"} to remotePatterns in next.config.ts, then it can become a WpImage fill with {sourceUrl, width:null, height:null}.\n\n2) SystemSection imageWidth/imageHeight: when imageSrc is overridden via WP with a non-square aspect, the hardcoded 2048x2048 defaults would NOT reflect the new ratio (the new props would need to be passed too). For the currently-shipped local asset this is exact. If WP-fed art for this block lands later, the call site should pass imageWidth/imageHeight, or these should accept null and let WpImage fall back to fill inside a sized wrapper.\n\n3) maxime avatar source is 400x224 (not square); object-cover into the 36x36 box crops it center - identical to the original native <img object-cover>, so parity holds, but worth noting the source isn't square."
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/industries/DistributionHero.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/industries/LogistiqueHero.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/industries/LogistiqueSolution.tsx"
        ],
        "skipped": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/industrie/IndustrieProof.tsx — both <img> are SVG that the rules say to keep inline: img#1 decorative /images/applicateur-blobs.svg (with mask-image radial-gradient style), img#2 /logos/*.svg industrial logos with brightness(0) invert(1) filter + object-contain. No WpImage/ArtDirectedImage conversion applicable. Zero edits made."
        ],
        "parityNotes": "All three migrations swap ONLY the <picture>/<img> for <ArtDirectedImage>, preserving exact classNames, DOM order, and stacking.\n\nDistributionHero.tsx: <picture className=\"absolute inset-0 -z-10\"> with <source media=\"(max-width:767px)\" srcSet=\"/images/distribution/hero-mobile.jpg\"> + <img src={imageSrc} className=\"size-full scale-[1.02] object-cover\">. Mapped to ArtDirectedImage: mobile={sourceUrl:'/images/distribution/hero-mobile.jpg'}, desktop={sourceUrl:imageSrc}, both shaped as WpImage {sourceUrl, altText:imageAlt, width:null, height:null} (local /images path → fill mode auto-selected). className=\"absolute inset-0 -z-10\" (the original <picture> class), imgClassName=\"size-full scale-[1.02] object-cover\" (the original <img> class), alt={imageAlt}, priority (above-the-fold hero, replaces loading=\"eager\"). breakpoint defaults to \"md\" = original (max-width:767px). The two -z-10 gradient overlay <div>s that follow remain after it in document order, so they paint over the image exactly as before (same -z-10 stacking context, later-in-DOM siblings on top).\n\nLogistiqueHero.tsx: identical pattern. <source srcSet=\"/images/industrie-logistique-hero-mobile.jpg\"> + <img src={imageSrc} className=\"size-full object-cover scale-[1.02]\">. ArtDirectedImage with mobile/desktop WpImage shapes, className=\"absolute inset-0 -z-10\", imgClassName=\"size-full object-cover scale-[1.02]\", alt={imageAlt}, priority. The \"{/* ── PHOTO BG full-bleed ── */}\" comment preserved.\n\nLogistiqueSolution.tsx: <picture> (no className) with <source srcSet={item.mobileImage}> + <img src={item.image} className=\"size-full object-cover object-[center_58%] transition-transform duration-700 ease-out group-hover:scale-[1.025] md:object-center\" loading=\"lazy\">. Inside a .map() over comparisons, each in parent <div className=\"relative aspect-[1.05] overflow-hidden ...\"> (already positioned). ArtDirectedImage with mobile={sourceUrl:item.mobileImage}, desktop={sourceUrl:item.image} WpImage shapes, alt={item.alt}, imgClassName carries the full original <img> class verbatim (group-hover scale + object-position transitions preserved), NO priority (content section, was loading=\"lazy\"). The original <picture> had no className; since ArtDirectedImage renders two <Image fill> needing a positioned wrapper, I set the wrapper className=\"absolute inset-0\" so it fills the positioned aspect-ratio card exactly as the old size-full <img> did. The gradient overlay <div> (absolute bottom-0 h-2/3) and label <span> (absolute left-5 top-5) follow it in document order with no z-index, so they paint on top exactly as before.\n\nImports added: `import ArtDirectedImage from '@/components/ui/ArtDirectedImage'` in all three. No now-unused imports to remove (no next/image was previously imported in these files; useState/lucide icons all still used). Full `tsc --noEmit` passes with exit 0.",
        "uncertainties": "1. LogistiqueSolution wrapper className: original <picture> had NO class and relied on its child <img> being size-full to fill. ArtDirectedImage's two inner <Image fill> require a positioned/sized wrapper, so I gave the wrapper `absolute inset-0` (the parent card div is `relative`). This reproduces the same full-bleed fill of the aspect-ratio card. Visually identical, but it is the one place I added a positioning class to the wrapper rather than copying an existing <picture> class verbatim (because there was none to copy). The img-level fill behavior is otherwise byte-for-byte preserved in imgClassName.\n\n2. ArtDirectedImage gives the DESKTOP <Image> alt=\"\" aria-hidden and the MOBILE one the real alt (per the wrapper's a11y design). Original <img> had a single alt on the desktop default. Net SR behavior is equivalent (one announced image), per the wrapper API; not a pixel concern.\n\n3. Local /images/*.jpg and imported .webp (.src) paths are passed as WpImage {width:null,height:null} → WpImage/ArtDirectedImage fill mode. Per the API doc, next/image serves local-path images without remotePattern matching, so these are fine without next.config changes. Did NOT run build/dev per instructions, so runtime rendering of these local-path fill images was not visually confirmed — only typecheck.\n\n4. IndustrieProof.tsx fully skipped (no WP/local-photo <img>, only SVG kept inline per rules) — confirm this matches the orchestrator's expectation that \"wp-mixed\" files may end with zero edits."
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/industries/SectorPageTemplate.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/product/ProductShowcase.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/product/covametal/CovaMetalShowcase.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/product/covaseal/CovaSealShowcase.tsx"
        ],
        "skipped": [
          "SectorPageTemplate hero-logos <img> (line 473) and proof-logos <img> (line 1186): external/public logo SVGs with object-contain + brightness(0) invert(1) filter — left inline per rules (do NOT convert SVG logos).",
          "ProductShowcase, CovaMetalShowcase, CovaSealShowcase: the two decorative /images/applicateur-blobs.svg <img> in each (6 total) — inline SVG decorations with CSS mask, left as-is per rules."
        ],
        "parityNotes": "All 7+9 raw <img> reviewed; 8 photographic/product images migrated, the rest are SVG logos/decorations left inline per rules. className, object-cover/contain, rounded/aspect/sizing, and DOM structure preserved verbatim on every swapped image.\n\nSectorPageTemplate.tsx (5 WpImage, all WP-fed via toSectorConfig string srcs -> public /images, or local webp .src fallback):\n- Added a tiny local helper toFillImage(src, alt) => { sourceUrl, altText, width: null, height: null }. All five are full-bleed object-cover in positioned/sized parents, so WpImage fill (matching the existing migrated-block pattern in Hero.tsx/CompatibiliteSupports.tsx).\n- (1) Hero config.hero.image: absolute inset-0 -z-10 size-full scale-[1.02] object-cover -> WpImage fill priority sizes=\"100vw\". This is the first/above-the-fold hero (LCP candidate; template is not rendered via BlockRenderer so there is no position prop) -> priority per rule. Original loading=\"eager\"/decoding dropped (next/image handles via priority).\n- (3) problem.image (aspect-[4/3] relative parent) -> fill, sizes for 1/2/4-col grid.\n- (4) solution item.image = config.solution.beforeImage ?? classicRoofImage.src / afterImage ?? coolRoofImage.src (aspect-[1.1]/md:aspect-[1.18] relative parent) -> fill, sizes 50vw/100vw. classicRoofImage/coolRoofImage imports retained.\n- (5) benefits figure config.benefits.image (figure absolute ... w-[58vw] lg:block) -> fill alt=\"\" sizes=\"58vw\".\n- (6) applications card.image (aspect-[4/3] parent) -> fill alt=\"\" sizes 20vw/100vw. The applications container lacked `relative`; added `relative` (no visual change, only establishes positioning context fill requires). The problem container already had relative.\n\nProduct packshots (ProductShowcase, CovaMetal, CovaSeal): object-contain, NOT full-bleed (h-64 w-auto / w-[85%] h-auto + lg:absolute) -> WpImage INTRINSIC per rule, NOT fill. Forcing fill would break the w-auto/absolute layout. Intrinsic dims sourced from the StaticImageData webp imports (.width/.height), preserving srcset correctness while the forwarded className (h-64 w-auto object-contain, etc.) governs visual sizing exactly as before.\n- ProductShowcase: added optional imgWidth/imgHeight to the Variant type, populated from covaTherm8Img/covaTherm20Img static imports in defaultVariants; render builds { sourceUrl: active.img, altText: active.alt, width: active.imgWidth ?? null, height: active.imgHeight ?? null }. key={active.id} preserved on the WpImage to keep the variant-switch remount/transition. The origine adapter spreads ...base so dims propagate; custom variants without dims fall back to fill (parent is relative).\n- CovaMetal/CovaSeal: added optional imageWidth/imageHeight props. Dims resolve to the local webp dims ONLY when image === covaXxxImg.src (default), else null. This is deliberate: makeShowcase can pass a WP image={img.sourceUrl} with unknown dims — attaching the local webp's dims to a different WP image would distort it, so those fall back to fill.\n\nnext.config.ts qualities [70,80,90] and remotePatterns (WP host whitelisted) already cover these images; local /images and /assets paths are served by next/image without remotePattern matching. No quality prop passed (project default). tsc --noEmit passes with zero errors.",
        "uncertainties": "1) Product packshot fill-fallback edge case: if CovaMetal/CovaSeal ever receive a WP image override (via makeShowcase), dims are null and WpImage auto-falls-back to fill. The packshot's outer div is `relative` so fill is contained, but the w-[85%]/h-auto/lg:absolute classes would then fight fill's inset:0 — visually imperfect for that path. In practice the seeded \"Positionnement\" blocks use the same local packshot, and current views (CovaTherm/CovaMetal/CovaSeal.tsx) call the components with no props, so the default-dim intrinsic path is what renders. Flagging in case WP later feeds a real differently-sized packshot media here.\n2) Added `relative` to the applications-card image container in SectorPageTemplate (required for fill). It is a no-op visually but is a (minimal) class addition beyond a pure tag swap — called out for transparency.\n3) sizes values I chose for the fill images are best-effort matches to the responsive grid widths (the original native <img> had no sizes attribute). They improve srcset selection without affecting layout; adjust if a tighter sizes contract is preferred."
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/product/covatherm-light/CovaThermLightShowcase.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/views/BlogArticle.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/views/DevenirApplicateur.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/roof/RoofPageTemplate.tsx"
        ],
        "skipped": [
          "RoofPageTemplate.tsx img#2 trustLogos (/logos/*.svg, object-contain brightness-0 invert) — external SVG logos, left inline per rules",
          "RoofPageTemplate.tsx img#10 proof logos (/logos/*.svg, object-contain, filter brightness(0) invert(1)) — external SVG logos, left inline",
          "DevenirApplicateur.tsx img#2 & img#5 (/images/applicateur-blobs.svg, decorative with mask/opacity) — SVG, left inline",
          "CovaThermLightShowcase.tsx img#1 & img#2 (/images/applicateur-blobs.svg, decorative) — SVG, left inline"
        ],
        "parityNotes": "All 11 converted images use WpImage (no <picture> art-direction case appeared — DevenirApplicateur's <picture> had NO <source>, so it was treated as a plain hero fill, not ArtDirectedImage). ArtDirectedImage was not needed in this group. Every className (object-cover/object-contain, rounded-*, sizing, drop-shadow, scale, hover transitions, opacity) was preserved verbatim on the rendered image.\n\nThese are all LOCAL public-path strings or imported .webp assets (plus one whitelisted remote covalba.fr URL for the blog hero), not mapImage() output, so each was shaped as a WpImage object { sourceUrl, altText, width, height } per the API notes.\n\nfill vs intrinsic decisions:\n- fill (full-bleed object-cover/contain in positioned parent): RoofPageTemplate heroImage (#1, +priority), visual.hot/cool (#3/#4), maxime avatar (#6, wrapped in relative h-9 w-9 overflow-hidden rounded-full), getBenefitImage (#8); BlogArticle InlineCTA bg (#1) + body image (#2) + hero (#3, +priority); DevenirApplicateur hero (#1, +priority), offer.image (#3), implImage (#4).\n- intrinsic (explicit width/height, not full-bleed, w-full or max-w/contain): RoofPageTemplate coolRoofLayers mobile (#5) & desktop (#7) using coolRoofLayers.width/height; situation packshots (#9) — getSituationImage changed to return the imported asset object so width/height flow through; CovaThermLightShowcase packshot using covaThermLightImg.width/height.\n\npriority applied to the 3 above-the-fold page heroes (RoofHero, BlogArticle header, DevenirApplicateur hero). These are full-page views, NOT BlockRenderer blocks, so none receive a position prop — priority is set directly on the hero. Everywhere else priority is left default (false) for lazy-loading.\n\nDOM/parity-neutral container tweaks required for fill (which forces position:absolute;inset:0 and needs a positioned, height-defined parent):\n- BlogArticle InlineCTA: added `relative` to the existing image wrapper div.\n- BlogArticle body image: moved `aspect-[16/9]` from the <img> onto the <figure> and added `relative` (fill ignores aspect-ratio on an absolutely-positioned element).\n- BlogArticle hero: wrapped the fill image in a `relative aspect-[16/8] sm:aspect-[16/10] lg:aspect-[16/11]` div (the <figure> also holds a <figcaption> sibling, so the aspect box had to be a dedicated child, not the figure).\n- DevenirApplicateur hero: replaced `<picture className=\"absolute inset-0 -z-10\">` with WpImage fill carrying `-z-10` in className (the section is already relative isolate).\n- DevenirApplicateur offer card: added `relative` to the `aspect-[3/2]` div.\n- DevenirApplicateur implImage: moved `min-h-[340px]` onto the wrapper and added `relative`.\n- RoofPageTemplate heroImage: original was already absolute inset-0 -z-10; fill provides absolute inset-0, `-z-10 size-full scale-[1.02] object-cover` kept in className. The conditional inline `style={{objectPosition:'center 28%'}}` for membrane-bitumineuse was reproduced as the arbitrary class `object-[center_28%]` (WpImage has no style prop).\n- RoofPageTemplate avatar: wrapped in `relative mt-0.5 h-9 w-9 shrink-0 overflow-hidden rounded-full` (moved layout+clip classes to the box, object-cover on the fill image).\n\nRemoved now-unused per-image attributes that next/image manages: loading=\"eager|lazy\" and decoding=\"async\" (next/image handles loading via priority + intrinsic lazy default; the 3 heroes use priority). No other code became unused — coolRoofLayers and all product packshot imports are still referenced (now for their .width/.height too). tsc --noEmit on the whole project: 0 errors.",
        "uncertainties": "1. CovaThermLightShowcase packshot and RoofPageTemplate situation packshots use the IMPORTED asset's intrinsic width/height (covaThermLightImg / covaMetalProduct etc.). If WP later overrides the `image` prop (CovaThermLight) with a differently-proportioned URL, the passed width/height would be stale and could cause CLS/distortion. For the current default-asset path it is pixel-exact. Both are object-contain so aspect mismatch would letterbox rather than stretch, but it is a theoretical risk if these become WP-fed with mismatched dims.\n2. The DevenirApplicateur hero <picture> had no <source>, so I read the task note literally ('NO <source>, not art-dir -> public local fill hero') and used WpImage fill rather than ArtDirectedImage. If the intent was eventually to art-direct it, ArtDirectedImage would be the swap — but with a single source today, fill is the correct parity-preserving choice.\n3. RoofPageTemplate membrane-bitumineuse objectPosition: I converted `objectPosition:'center 28%'` to the Tailwind arbitrary class `object-[center_28%]`. This is statically present in the source so Tailwind will JIT it; visually identical to the inline style. Did not run a build, so I did not visually confirm the arbitrary value renders (tsc is clean and the class is a standard arbitrary object-position utility).\n4. Did NOT run build/dev per instructions; verification was tsc --noEmit only (0 errors)."
      },
      {
        "changed": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/views/PrimeCEECoolRoof.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/views/QuiSommesNous.tsx"
        ],
        "skipped": [
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/views/ReferenceDetail.tsx",
          "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/views/References.tsx"
        ],
        "parityNotes": "PrimeCEECoolRoof.tsx (3 imgs): Added `import WpImage from '@/components/ui/WpImage'`. img#1 — hero inside `<picture className='absolute inset-0 -z-10'>` (no <source>): swapped raw <img src={heroImage} h-full w-full object-cover opacity-34 loading=eager> for `<WpImage image={{sourceUrl: heroImage, altText: heroImageAlt, width: null, height: null}} fill priority className='h-full w-full object-cover opacity-34' />`. The `<picture>` is kept as the positioned container so DOM/positioning is identical; className verbatim; priority because it is the above-the-fold hero; eager/decoding behavior replaced by next/image's priority (eager) semantics. img#2 & img#3 — `/images/applicateur-blobs.svg` decorative overlays: KEPT INLINE as raw <img> per the SVG rule (not converted).\n\nQuiSommesNous.tsx (11 imgs): Added the WpImage import. Converted 7 local-raster images to WpImage; kept 4 as inline <img> (3 SVG/logo + 1 dimension-less object-contain). Details:\n- img#1 hero in `<picture className='absolute inset-0 -z-10'>` (no <source>): -> WpImage fill priority, className 'h-full w-full object-cover opacity-42' verbatim, <picture> kept as positioned container.\n- missionImage figure (was img `aspect-[4/5] h-full w-full object-cover`): -> WpImage fill, className 'h-full w-full object-cover'. Moved the box-sizing `aspect-[4/5]` and added `relative` to the parent <figure> (which is the same visual box; fill requires a positioned/sized parent). No DOM node added.\n- 2 secondary mission figures (brief-technique-toiture.webp, stock-produits-airless.webp; was `aspect-[4/3] h-full w-full object-cover sm:aspect-auto`): -> WpImage fill, className 'h-full w-full object-cover'. Moved `aspect-[4/3] sm:aspect-auto` + added `relative` to each <figure> (kept its existing sm:min-h-0); the sm:aspect-auto box continues to be driven by the sm:grid-rows-2 stretch exactly as before.\n- behindScenes (3 imgs `h-full w-full object-cover` inside `<div aspect-[7/5] w-full overflow-hidden>`): -> WpImage fill, className unchanged; only added `relative` to the already-sized aspect div (no class move needed, no DOM change).\n- companyGallery carousel (img `aspect-[4/3] h-full w-full object-cover transition-transform duration-700 group-hover:scale-105` in `<figure group relative ...>`): -> WpImage fill, kept full className incl. the hover-scale transition; moved `aspect-[4/3]` onto the already-relative <figure>. The absolutely-positioned <figcaption> overlay is untouched and overlays identically.\n- aventureImage (img `aspect-[11/8] w-full object-cover` in a <figure> that ALSO contains a flow <figcaption>): wrapped the WpImage fill in a single `<div className='relative aspect-[11/8] w-full'>` so only the image area carries the 11/8 ratio and the figcaption keeps flowing below — same pixels. This is the only place a wrapper <div> was introduced, and it is the standard next/image fill idiom (no visual change).\n- priority: only the two hero images (PrimeCEE + QuiSommesNous) get priority; every WpImage elsewhere is default priority=false so next/image lazy-loads, matching the original loading='lazy'.\nAll converted WpImages use the local `/images/*.jpg|webp` path shaped as `{sourceUrl, altText, width: null, height: null}`; null dims auto-select fill mode per the wrapper contract, and every such parent is now positioned/sized so the fallback is exact.",
        "uncertainties": "QuiSommesNous timeline image (line ~713, `item.image` *.png, `max-h-28 max-w-28 object-contain` inside a centered flex `min-h-36` box) was KEPT INLINE despite the note saying 'local intrinsic'. Reason: it is a local string path with no width/height, and the WpImage intrinsic mode REQUIRES both width and height (the block-level Timeline.tsx only gets intrinsic because real WP mapImage() supplies dims). With null dims WpImage auto-falls-back to fill, and fill in a centered flex box would ignore `max-h-28 max-w-28 object-contain` and break the layout. Converting faithfully would require fabricating intrinsic dimensions (restyle) — out of scope. Flag for Tom: when this view is fed by real WP media (mapImage with dims), switch it to `<WpImage image={...} className='max-h-28 max-w-28 object-contain ...' />` (intrinsic).\n\nReferences.tsx and ReferenceDetail.tsx were SKIPPED, not migrated, because faithful migration is not safely achievable with the current config/API:\n1. Remote hosts not whitelisted: `reference.image` values are mostly remote — `5524202.fs1.hubspotusercontent-na1.net` (HubSpot CDN) and `img.youtube.com` (YouTube thumbnails). next.config.ts remotePatterns only whitelists `covalba-admin.paf-studio.dev/wp-content/uploads/**` (+ optional NEXT_PUBLIC_WP_MEDIA_URL host). next/image would throw 'hostname not configured' at runtime for every remote reference image. Whitelisting third-party CDNs in remotePatterns is a config decision outside this swap task.\n2. References.tsx fallback `reference.image ?? '/placeholder.svg'` is an SVG; next.config has no `dangerouslyAllowSVG`, so even the fallback would fail under next/image.\n3. ReferenceDetail.tsx additionally uses `onError={() => setImageFailed(true)}` to swap to a branded placeholder block — the WpImage API exposes no onError prop, so migrating would silently drop that functional fallback.\nRecommendation for Tom: to migrate these two, first add the HubSpot + YouTube hosts (and `dangerouslyAllowSVG` for the placeholder) to next.config images, and extend WpImage with an onError passthrough (or keep the manual fallback logic). Then ReferenceDetail's figure (`aspect-[16/11] w-full object-cover`) and References' card (already `relative aspect-[16/10]`, gradient overlay sibling) are both clean fill candidates."
      }
    ],
    "verdicts": [
      {
        "issues": [
          {
            "severity": "blocker",
            "area": "pixel-parity / fill collapse",
            "file": "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/blocks/TexteImage.tsx",
            "description": "The image uses `fill` (line 33) inside `<div className=\"relative rounded-[2rem] overflow-hidden shadow-...\">` (line 30) which has NO height constraint — no aspect ratio, no fixed/min height. With next/image `fill`, the <img> becomes position:absolute; inset:0 and leaves normal flow, so this `relative` parent collapses to 0 height and the image renders invisible. This is a real visual regression. Confirmed against the source it was 'repris de' — CoolRoofExplainerSection.tsx line 40 has `aspect-square` on the container, which gives the image its height; that class was dropped here. Secondary parity drift on the same image: className is `object-cover` (line 34) whereas the original used `object-contain p-4` (CoolRoofExplainerSection line 44), so even after fixing the collapse the image would crop/scale differently and lose the inner padding.",
            "suggestedFix": "Add a defined height to the container at line 30, e.g. add `aspect-square` (matching the original): `<div className=\"relative aspect-square rounded-[2rem] overflow-hidden shadow-...\">`. To fully match the original visual, also change the image className from `object-cover` to `object-contain p-4`. Alternatively, drop `fill` and let WpImage use intrinsic dimensions (WP mediaDetails width/height) so the container is sized by the image itself."
          },
          {
            "severity": "minor",
            "area": "pixel-parity / fill collapse risk (data-dependent)",
            "file": "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/components/product/ProductShowcase.tsx",
            "description": "WpImage at line 159 passes `width: active.imgWidth ?? null, height: active.imgHeight ?? null`. The packshot relies on INTRINSIC mode (className `h-64 w-auto` / `lg:absolute lg:h-[420px] lg:-left-14`, a floating overflow element). If imgWidth/imgHeight are ever absent, WpImage auto-falls-back to `fill`, which would turn the floating packshot into an `absolute inset-0` cover image inside `relative min-h-[280px]` — breaking the layout. In the intended path (local webp imports provide dimensions) this is fine, but the fallback path is a latent parity trap for this specific non-cover layout.",
            "suggestedFix": "Ensure imgWidth/imgHeight are always supplied for the packshots (assert at the data layer), or guard this component to never enter fill mode (e.g. require dimensions and render nothing / a sized placeholder otherwise)."
          },
          {
            "severity": "minor",
            "area": "pixel-parity / fill height via grid stretch (verify visually)",
            "file": "/opt/projects/.covalba-wp-worktrees/tom-passage-wp/src/views/QuiSommesNous.tsx",
            "description": "The two stacked figures at lines 483 and 495 use `fill` inside `relative aspect-[4/3] ... sm:aspect-auto sm:min-h-0`, nested in `grid sm:grid-rows-2` inside the right column of `grid sm:grid-cols-[1.2fr_0.8fr]`. Below `sm` they have `aspect-[4/3]` (sized — fine). At `sm+` the height comes ONLY from the grid row track being stretched to match the left column's `aspect-[4/5]` image. This chain is plausible and likely intentional (the explicit `sm:aspect-auto sm:min-h-0` shows the author meant this), but it is fragile: if the left column ever shrinks or the stretch breaks, these `fill` cells collapse to 0. Not a clear regression, flagged for a visual check at >=640px.",
            "suggestedFix": "Visually verify the mission grid at the sm and lg breakpoints. If solid, no change needed; if fragile, give the right-column rows an explicit min height (e.g. `sm:min-h-[180px]`) instead of relying solely on grid stretch."
          }
        ],
        "buildWillBreak": false,
        "summary": "Adversarial pixel-parity review of the next/image migration. The two core wrappers are sound: WpImage forwards className verbatim to the rendered <img> (object-cover/rounded/sizing preserved), null-guards, preserves alt (alt ?? image.altText ?? \\\"\\\"), and auto-falls-back to fill only when intrinsic dimensions are missing. ArtDirectedImage correctly uses two sibling <Image fill> with STATIC Tailwind breakpoint classes (md = original 767px media query), shared alt on the visible one and aria-hidden on the hidden one. tsc --noEmit passes (exit 0); fetchPriority is a valid next/image prop in Next 16.2.6; next.config.ts allowlists the WP media host so remote images render. The build will NOT break.\\n\\nI traced EVERY fill / ArtDirectedImage usage across all changed components. One hard regression: src/components/blocks/TexteImage.tsx uses `fill` inside a `relative` container that has no height (the `aspect-square` present in the original CoolRoofExplainerSection was dropped) — the parent collapses to 0 height and the image is invisible. Same file also drifts from the original by using object-cover instead of object-contain p-4. That is the only blocker.\\n\\nEverything else checks out for parity: Hero, LocalHero, SectorsGrid, WinterObjectionSection (flex-stretch height), blocks/Hero (incl. compact), AvantApres (kept aspect-square), Citation, CompatibiliteSupports, GrilleCards, ReferencesGrille, TemoignagesVideo, all of SectorPageTemplate and RoofPageTemplate, all three ArtDirectedImage call sites (DistributionHero/LogistiqueHero/LogistiqueSolution), and the views (BlogArticle, DevenirApplicateur, PrimeCEECoolRoof, QuiSommesNous) all place fill inside positioned + sized parents (aspect-*, min-h-*, h-*, or absolute inset-0 within a sized section), with object-cover/object-contain and rounded classes preserved and alt text intact. Two minor/latent items flagged per the 'default to flagging if uncertain' instruction: a data-dependent fill-fallback risk on the ProductShowcase floating packshot, and the QuiSommesNous nested-grid figures whose sm+ height relies on grid-stretch (verify visually)."
      },
      {
        "buildWillBreak": false,
        "summary": "Verified empirically, not just by inspection: `npx tsc --noEmit` exits 0 and a full `next build` (Next 16.2.6, Turbopack) exits 0 — it compiles, runs the TypeScript phase (15.9s, no errors), and statically generates all 48 routes. The next/image + next/font migration is build-safe and type-safe.\n\nWRAPPERS: src/components/ui/WpImage.tsx and src/components/ui/ArtDirectedImage.tsx compile with correct next/image props. WpImage handles the missing-dimensions case well: when image.width/height are null it auto-switches to `fill` (sizes defaults to \"100vw\"), avoiding next/image's \"missing required width/height\" runtime error; the intrinsic branch casts width/height via `as number` which is type-clean under the project's relaxed tsconfig (strict:false, strictNullChecks:false). `fetchPriority` is a valid prop on Next 16's Image (ImageProps extends JSX.IntrinsicElements['img']). ArtDirectedImage uses static Tailwind breakpoint classes (sm/md/lg) via a literal lookup table — correctly avoiding dynamic class interpolation that Tailwind can't detect. All 3 ArtDirectedImage call sites (LogistiqueHero, DistributionHero, LogistiqueSolution) and ~30 WpImage call sites pass correct prop names and shapes; type-check confirms.\n\nCONFIG: next.config.ts is valid — images.remotePatterns (WP host + optional env-derived CDN), deviceSizes, imageSizes, qualities:[70,80,90] (Next 16 requires whitelisting), formats, redirects, and allowedDevOrigins from env. No typescript.ignoreBuildErrors, so the passing build is meaningful.\n\nFONTS: app/layout.tsx wires Plus_Jakarta_Sans -> --font-body and Space_Grotesk -> --font-display via next/font/google, applied through className on <html>. Both vars are consumed: --font-body in index.css (body, .font-body) and tailwind font-body; --font-display via tailwind font-display class (ProductHero.tsx, GuideCoolRoof.tsx). The old Google Fonts <link> tags for these two families were correctly removed. Satoshi intentionally stays on Fontshare via a valid <head> <link> (no local woff2 in repo) and is consumed by h1-h6/.font-satoshi. No leftover broken or duplicate font links in the App Router tree.\n\nThe migration was PARTIAL by design: only WP-fed images were moved to the wrappers. Many static/local-asset <img> tags remain (marquee logos, decorative SVGs, etc.) but these are valid JSX, not dangling/broken. They do NOT break the build: eslint-config-next is not installed and the project's flat ESLint config (Vite-era) does not enable @next/next/no-img-element, and Next 16 does not lint-fail the build with no Next ESLint plugin present.",
        "issues": [
          {
            "severity": "minor",
            "area": "Migration completeness / consistency",
            "description": "The migration is partial: only WP-fed images were moved to WpImage/ArtDirectedImage. Many modified AND unmodified files still render raw <img> (e.g. Hero.tsx marquee logos lines 97 & 216, TemoignagesVideo.tsx decorative SVG line 18, Navbar.tsx line 134, plus ~60 unmigrated component files). These are valid JSX and do not break the build, but they bypass next/image optimization and would normally trigger @next/next/no-img-element. The rule never fires because eslint-config-next is NOT installed and the flat ESLint config does not extend next/core-web-vitals.",
            "file": "src/components/Hero.tsx",
            "suggestedFix": "This is acceptable if intentional (local SVG/dynamic logo URLs that don't benefit from optimization). If full coverage is desired, convert remaining static-asset <img> to next/image (or a local-asset wrapper). Separately, consider installing eslint-config-next + wiring next lint so no-img-element actually guards future regressions."
          },
          {
            "severity": "minor",
            "area": "Dead config / leftover",
            "description": "index.html (Vite entry, unused by Next App Router) still contains the old Google Fonts <link> tags for Plus Jakarta Sans (line 25) and Space Grotesk (line 27) that were removed from app/layout.tsx. Harmless dead code — Next ignores index.html — but it is now stale and contradicts the next/font migration.",
            "file": "index.html",
            "suggestedFix": "Remove the obsolete Google Fonts <link> tags from index.html (or delete index.html if the Vite path is fully retired) to avoid confusion."
          },
          {
            "severity": "minor",
            "area": "next/font font-display strategy",
            "description": "Both next/font families use display:\"swap\" while Satoshi (titles, used by all h1-h6) is loaded via a render-blocking Fontshare <link rel=\"stylesheet\"> with its own display=swap. This is correct for build/type safety and parity, but headings depend on a third-party blocking stylesheet rather than next/font, so they are not self-hosted/preloaded like the body fonts. Not a build issue.",
            "file": "app/layout.tsx",
            "suggestedFix": "If a fully self-hosted/preloaded heading font is desired later, add Satoshi woff2 to the repo and load via next/font/local; otherwise the preconnect hints already mitigate the blocking cost."
          }
        ]
      }
    ]
  }
}