---
phase: 06-blog-pages
plan: 04
type: execute
wave: 3
depends_on:
  - 06-01
  - 06-02
files_modified:
  - app/pages/blog/[slug].vue
  - app/components/BlogToc.vue
  - app/components/BlogPrevNext.vue
autonomous: true
requirements:
  - BLOG-03
  - BLOG-06
tags:
  - blog
  - article-chrome
  - toc
  - prev-next
  - intersection-observer

must_haves:
  truths:
    - "`curl localhost:3000/fr/blog/test-kotlin-syntax` retourne HTML SSR avec (ordre vertical) : UBreadcrumb (Accueil → Blog → titre) → H1 du titre → ligne meta (date formatée + · + reading time) → tags UBadge (si présents) → cover image NuxtImg (si frontmatter.image) → body markdown dans `.prose` → BlogPrevNext (en bas)"
    - "`curl localhost:3000/en/blog/test-kotlin-syntax` retourne la même structure avec textes EN (breadcrumb Home → Blog → title)"
    - "La page article filtre le `draft` dans la query de SURROUND (prev/next) — les articles draft:true ne viennent pas peupler la navigation. La query `.path(path).first()` n'a PAS le filtre (URL directe accessible pour les tests, D-14)"
    - "Sur desktop (>= lg), une `<aside>` sticky à droite contient la table des matières avec les headings h2/h3 de `page.body.toc.links`"
    - "Sur mobile (< lg), un UButton `i-lucide-list` dans la meta row ouvre un UDrawer side='right' contenant la même TOC"
    - "Le heading actif (premier visible dans la zone 20%-30% du viewport) est surligné `text-brand-500` via IntersectionObserver client-only (onMounted)"
    - "BlogPrevNext.vue rend 2 BlogCard variant='compact' avec direction='prev' et 'next'. Si un voisin est null, la cellule reste vide (alignement grid préservé, D-13)"
    - "Prev (article plus ancien) = `surround[1]`, Next (article plus récent) = `surround[0]` car order DESC retourne l'élément before la position courante dans l'ordre de la collection (Pitfall 4)"
    - "`queryCollectionItemSurroundings` utilise les littéraux 'blog_fr'/'blog_en' avec if/else (Phase 5 gotcha)"
  artifacts:
    - path: "app/pages/blog/[slug].vue"
      provides: "Page article enrichie : breadcrumb + header + TOC layout + surround + prev/next"
      contains: "queryCollectionItemSurroundings"
      contains_also: "UBreadcrumb"
      min_lines: 120
    - path: "app/components/BlogToc.vue"
      provides: "TOC sticky desktop + UDrawer mobile + IntersectionObserver highlight"
      contains: "IntersectionObserver"
      contains_also: "UDrawer"
    - path: "app/components/BlogPrevNext.vue"
      provides: "Grid 2 cols de BlogCard variant compact (prev + next)"
      contains: "variant=\"compact\""
  key_links:
    - from: "app/pages/blog/[slug].vue"
      to: "queryCollectionItemSurroundings('blog_fr'|'blog_en', path, ...)"
      via: "useAsyncData secondaire avec littéraux if/else + watch locale"
      pattern: "queryCollectionItemSurroundings"
    - from: "app/pages/blog/[slug].vue"
      to: "app/components/BlogToc.vue"
      via: "<BlogToc :links=\"page.body.toc.links\" ... />"
      pattern: "<BlogToc"
    - from: "app/pages/blog/[slug].vue"
      to: "app/components/BlogPrevNext.vue"
      via: "<BlogPrevNext :prev :next />"
      pattern: "<BlogPrevNext"
    - from: "app/components/BlogToc.vue"
      to: "DOM headings h2/h3 rendus par ContentRenderer"
      via: "IntersectionObserver sur document.getElementById(link.id) dans onMounted"
      pattern: "IntersectionObserver"
    - from: "app/components/BlogPrevNext.vue"
      to: "app/components/BlogCard.vue"
      via: "<BlogCard variant=\"compact\" direction=\"prev\"|\"next\" />"
      pattern: "<BlogCard"
---

<objective>
Terminer la phase en enrichissant la page article `/blog/[slug]` avec le chrome complet (breadcrumb, header riche, TOC sticky + drawer mobile, prev/next cards). Créer 2 composants : `BlogToc.vue` (sticky desktop + UDrawer mobile + IntersectionObserver) et `BlogPrevNext.vue` (grid 2 cols de BlogCard compact).

**Purpose:** Cette page satisfait les success criteria 2, 3, 4 de la phase (rendu SSR article, TOC visible, prev/next visibles). Elle consomme tous les artefacts précédents (schema Wave 1, BlogCard + i18n + nav Wave 2) et corrige le `isFr` non-réactif de Phase 5.

**Output:**
- `app/components/BlogToc.vue` (nouveau)
- `app/components/BlogPrevNext.vue` (nouveau)
- `app/pages/blog/[slug].vue` (modification substantielle de l'existant Phase 5)
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/STATE.md
@.planning/phases/06-blog-pages/06-CONTEXT.md
@.planning/phases/06-blog-pages/06-RESEARCH.md
@.planning/phases/06-blog-pages/06-PATTERNS.md
@.planning/phases/06-blog-pages/06-UI-SPEC.md
@app/pages/blog/[slug].vue
@app/components/layout/AppHeader.vue
@app/components/ProjectCard.vue
@app/components/content/ProseImg.vue

<interfaces>
<!-- Shape page.body.toc après ContentRenderer (RESEARCH §Pattern 3) -->
```typescript
interface TocLink {
  id: string         // anchor id auto-généré (kebab-case du heading text)
  depth: number      // 2 = h2, 3 = h3, etc.
  text: string
  children?: TocLink[]
}

interface PageBody {
  toc?: {
    title: string
    searchDepth: number
    depth: number
    links: TocLink[]
  }
  // ... autre champs (type minimal, value)
}
```

<!-- Shape queryCollectionItemSurroundings return -->
```typescript
// Signature
function queryCollectionItemSurroundings(
  collection: 'blog_fr' | 'blog_en',
  path: string,
  opts?: { before?: number, after?: number, fields?: string[] }
): ChainablePromise  // chain .where().order()

// Return: array de 2 éléments [before, after]
// En .order('date', 'DESC') : before = plus récent, after = plus ancien
// PITFALL 4 : UI "précédent" (plus ancien) = surround[1], UI "suivant" (plus récent) = surround[0]
```

<!-- BlogCard variant compact (créé Wave 2) -->
```vue
<BlogCard
  :article="prevArticle"
  variant="compact"
  direction="prev"
/>
```

<!-- Pattern UDrawer Nuxt UI v3 -->
```vue
<UDrawer v-model:open="tocDrawerOpen" side="right">
  <template #header>...</template>
  <template #body>...</template>
</UDrawer>
```

<!-- UBreadcrumb Nuxt UI v3 items shape -->
```typescript
items: Array<{ label: string, to?: string, icon?: string }>
```

<!-- État actuel app/pages/blog/[slug].vue (Phase 5 — minimal) -->
```vue
<script setup lang="ts">
const { locale } = useI18n()
const route = useRoute()
const slug = route.params.slug as string
const isFr = locale.value === 'fr'  // ❌ NON-RÉACTIF — à convertir en computed
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
// ... useAsyncData sans { watch: [locale] }
</script>
<template>
  <div class="mx-auto max-w-3xl px-4 py-12">
    <article class="prose dark:prose-invert max-w-none">
      <ContentRenderer v-if="page" :value="page" />
    </article>
  </div>
</template>
```

<!-- IntersectionObserver pattern (RESEARCH §Pattern 4 lignes 392-442) -->
```typescript
// rootMargin imposé UI-SPEC
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 }
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 4.1 : Créer app/components/BlogToc.vue (sticky desktop + UDrawer mobile + IntersectionObserver)</name>
  <files>app/components/BlogToc.vue</files>
  <read_first>
    - app/components/layout/AppHeader.vue (pattern USlideover v-model:open="mobileOpen" lignes 6 + 80-114 — UDrawer suit le même pattern d'API)
    - app/components/content/ProseImg.vue (pattern defineProps<Props> + withDefaults typé lignes 1-38)
    - .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogToc contract lignes 232-253 pour desktop + mobile + IntersectionObserver — valeurs rootMargin/threshold imposées)
    - .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 3 pour la shape TocLink + §Pattern 4 lignes 392-442 pour l'IntersectionObserver COMPLET + §Pitfall 2 hydration pour initial state)
    - .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogToc.vue lignes 256-310)
    - .planning/phases/06-blog-pages/06-CONTEXT.md (D-05 sticky desktop + drawer mobile, D-06 highlight IntersectionObserver client-only)
    - i18n/locales/fr.json / en.json (confirmer blog.toc.title et a11y.blogTocToggle existent après Wave 2)
  </read_first>
  <action>
Créer `app/components/BlogToc.vue`. Le composant reçoit `links: TocLink[]` via props, gère :
- Affichage desktop : `<aside>` sticky top-24 w-64 (hidden sur < lg)
- Affichage mobile : UButton trigger `i-lucide-list` + UDrawer side='right' (hidden sur >= lg)
- Highlight : IntersectionObserver dans `onMounted`, cleanup dans `onBeforeUnmount`
- `activeId = ref<string | null>(null)` initial (Pitfall 2 hydration mismatch)

**Fichier complet :**

```vue
<script setup lang="ts">
interface TocLink {
  id: string
  depth: number
  text: string
  children?: TocLink[]
}

interface Props {
  links: TocLink[]
}

const props = defineProps<Props>()
const { t } = useI18n()

const drawerOpen = ref(false)
const activeId = ref<string | null>(null)
let observer: IntersectionObserver | null = null

// Aplatir la TOC (inclure les children h3 sous h2)
const flatIds = computed(() => {
  const ids: string[] = []
  const collect = (nodes: TocLink[]) => {
    for (const node of nodes) {
      ids.push(node.id)
      if (node.children?.length) collect(node.children)
    }
  }
  collect(props.links)
  return ids
})

onMounted(() => {
  if (typeof window === 'undefined') return

  // Setup initial activeId au premier heading pour cohérence visuelle post-hydration
  activeId.value = flatIds.value[0] ?? null

  observer = new IntersectionObserver(
    (entries) => {
      // Prendre le premier heading visible dans la zone active (du plus haut au plus bas)
      const visible = entries
        .filter((e) => e.isIntersecting)
        .sort((a, b) => a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top)
      if (visible.length > 0) {
        activeId.value = visible[0]!.target.id
      }
    },
    { rootMargin: '-20% 0px -70% 0px', threshold: 0 },
  )

  for (const id of flatIds.value) {
    const el = document.getElementById(id)
    if (el) observer.observe(el)
  }
})

onBeforeUnmount(() => {
  observer?.disconnect()
  observer = null
})

function handleItemClick() {
  // Fermer le drawer mobile après clic sur un lien
  drawerOpen.value = false
}
</script>

<template>
  <!-- Desktop: aside sticky (hidden sur mobile) -->
  <aside class="hidden lg:block sticky top-24 w-64 self-start">
    <p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-4">
      {{ t('blog.toc.title') }}
    </p>
    <ol class="space-y-2 text-sm">
      <li v-for="link in links" :key="link.id">
        <a
          :href="`#${link.id}`"
          :class="[
            activeId === link.id
              ? 'text-brand-500 dark:text-brand-400 font-medium'
              : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
            'block transition-colors',
          ]"
        >
          {{ link.text }}
        </a>
        <ol v-if="link.children?.length" class="mt-1 ml-4 space-y-1">
          <li v-for="child in link.children" :key="child.id">
            <a
              :href="`#${child.id}`"
              :class="[
                activeId === child.id
                  ? 'text-brand-500 dark:text-brand-400 font-medium'
                  : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
                'block transition-colors',
              ]"
            >
              {{ child.text }}
            </a>
          </li>
        </ol>
      </li>
    </ol>
  </aside>

  <!-- Mobile: UButton trigger + UDrawer side='right' (hidden sur desktop) -->
  <div class="lg:hidden inline-block">
    <UButton
      variant="ghost"
      color="neutral"
      size="sm"
      icon="i-lucide-list"
      :aria-label="t('a11y.blogTocToggle')"
      @click="drawerOpen = true"
    >
      {{ t('blog.toc.title') }}
    </UButton>

    <UDrawer v-model:open="drawerOpen" direction="right">
      <template #header>
        <p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
          {{ t('blog.toc.title') }}
        </p>
      </template>
      <template #body>
        <ol class="space-y-3 text-sm p-4">
          <li v-for="link in links" :key="link.id">
            <a
              :href="`#${link.id}`"
              :class="[
                activeId === link.id
                  ? 'text-brand-500 dark:text-brand-400 font-medium'
                  : 'text-gray-600 dark:text-gray-300',
                'block transition-colors',
              ]"
              @click="handleItemClick"
            >
              {{ link.text }}
            </a>
            <ol v-if="link.children?.length" class="mt-2 ml-4 space-y-2">
              <li v-for="child in link.children" :key="child.id">
                <a
                  :href="`#${child.id}`"
                  :class="[
                    activeId === child.id
                      ? 'text-brand-500 dark:text-brand-400 font-medium'
                      : 'text-gray-500 dark:text-gray-400',
                    'block transition-colors',
                  ]"
                  @click="handleItemClick"
                >
                  {{ child.text }}
                </a>
              </li>
            </ol>
          </li>
        </ol>
      </template>
    </UDrawer>
  </div>
</template>
```

**Points critiques :**

1. **Nuxt UI v3 UDrawer prop name** : `direction` (pas `side` dans certaines versions — vérifier à l'exécution ; si erreur, replace `direction` par `side`). Le projet utilise USlideover avec `side="left"` dans AppHeader — UDrawer v3 utilise `direction`. À valider au build.
2. **`activeId = ref(null)` initial** (pas le premier heading en synchrone) — Pitfall 2. On le set à `flatIds[0]` dans `onMounted` après que le DOM soit prêt.
3. **`onBeforeUnmount` cleanup** — critique pour éviter memory leak au navigate entre articles (anti-pattern RESEARCH).
4. **`handleItemClick` ferme le drawer mobile** — UX standard quand on clique sur un lien d'ancre dans un drawer.
5. **Pas de `useState`** pour activeId — ref local (anti-pattern RESEARCH ligne 563).
6. **TOC nested rendue à 2 niveaux max** (h2 + h3 children) — hiérarchie imposée par UI-SPEC §BlogToc contract. Les h4+ ne sont pas affichés.
7. **Accent color uniquement sur actif** — UI-SPEC §Color §Accent §6 : `text-brand-500 dark:text-brand-400`. Tout le reste est gris neutre.
8. **Client-only garantit par `typeof window === 'undefined' return`** — défensif même si onMounted ne s'exécute que côté client.
  </action>
  <verify>
    <automated>test -f app/components/BlogToc.vue && grep -c "IntersectionObserver" app/components/BlogToc.vue && grep -c "UDrawer" app/components/BlogToc.vue</automated>
  </verify>
  <acceptance_criteria>
    - `test -f app/components/BlogToc.vue` retourne 0
    - `grep -c "interface TocLink" app/components/BlogToc.vue` retourne 1
    - `grep -c "IntersectionObserver" app/components/BlogToc.vue` retourne 2 (type + new)
    - `grep -c "UDrawer" app/components/BlogToc.vue` retourne 1+ match
    - `grep "rootMargin: '-20% 0px -70% 0px'" app/components/BlogToc.vue` retourne 1 match
    - `grep "threshold: 0" app/components/BlogToc.vue` retourne 1 match
    - `grep -c "onMounted" app/components/BlogToc.vue` retourne 1
    - `grep -c "onBeforeUnmount" app/components/BlogToc.vue` retourne 1
    - `grep "observer?.disconnect()" app/components/BlogToc.vue` retourne 1 match (cleanup)
    - `grep "activeId = ref" app/components/BlogToc.vue` retourne 1 match
    - `grep "hidden lg:block sticky top-24" app/components/BlogToc.vue` retourne 1 match (desktop aside)
    - `grep "lg:hidden" app/components/BlogToc.vue` retourne 1+ match (mobile wrapper)
    - `grep "text-brand-500 dark:text-brand-400" app/components/BlogToc.vue` retourne 2+ matches (active state desktop + mobile)
    - `grep -c "t('blog.toc.title')" app/components/BlogToc.vue` retourne 2+ matches (desktop header + mobile header/button)
    - `grep -c "t('a11y.blogTocToggle')" app/components/BlogToc.vue` retourne 1
    - `grep "useState" app/components/BlogToc.vue` retourne rien (anti-pattern évité)
    - `pnpm typecheck` passe
    - `pnpm lint` passe
  </acceptance_criteria>
  <done>
BlogToc.vue créé. Desktop : `<aside>` sticky top-24 avec liste nested h2/h3, highlight brand-500 sur actif. Mobile : UButton trigger + UDrawer direction='right' avec même contenu. IntersectionObserver avec rootMargin/threshold UI-SPEC dans onMounted, cleanup onBeforeUnmount. activeId ref local (pas useState). Accepte `links: TocLink[]` via props.
  </done>
</task>

<task type="auto" tdd="false">
  <name>Task 4.2 : Créer app/components/BlogPrevNext.vue (grid 2 cols de BlogCard compact)</name>
  <files>app/components/BlogPrevNext.vue</files>
  <read_first>
    - app/components/BlogCard.vue (créé Wave 2 Task 2.3 — confirmer le contrat : variant="compact" + direction="prev"|"next")
    - .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogCard variant contract compact lignes 222-230 + §Interaction Contract lignes 321 pour le hover)
    - .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogPrevNext.vue lignes 313-340 pour le composition pattern)
    - .planning/phases/06-blog-pages/06-CONTEXT.md (D-09 style cards + D-10 pas d'image + D-13 case vide si absent)
    - i18n/locales/fr.json / en.json (a11y.blogPrev et a11y.blogNext avec interpolation {title} existent après Wave 2)
  </read_first>
  <action>
Créer `app/components/BlogPrevNext.vue`. Wrapper `<nav>` avec grid 2 cols md, affiche 2 BlogCard variant=compact. Si un voisin est null, cellule vide préservée pour alignement (D-13).

**Fichier complet :**

```vue
<script setup lang="ts">
interface SurroundArticle {
  path: string
  title: string
  description?: string
  date: string
  tags?: string[]
  image?: string
  minutes?: number
}

interface Props {
  prev: SurroundArticle | null
  next: SurroundArticle | null
}

defineProps<Props>()
const { t } = useI18n()
</script>

<template>
  <nav
    v-if="prev || next"
    class="mt-16 grid md:grid-cols-2 gap-5"
    :aria-label="t('blog.prevArticle') + ' / ' + t('blog.nextArticle')"
  >
    <!-- Prev (article plus ancien dans order DESC) -->
    <div v-if="prev">
      <BlogCard :article="prev" variant="compact" direction="prev" />
    </div>
    <div v-else aria-hidden="true" />

    <!-- Next (article plus récent dans order DESC) -->
    <div v-if="next">
      <BlogCard :article="next" variant="compact" direction="next" />
    </div>
    <div v-else aria-hidden="true" />
  </nav>
</template>
```

**Points critiques :**

1. **D-13 : cellule vide préservée** — `<div v-else aria-hidden="true" />` maintient la grille à 2 colonnes même si un seul voisin existe. `aria-hidden` évite que les screen readers annoncent un div vide.
2. **Pas de rendu du `<nav>` si les deux sont null** — `v-if="prev || next"` garde le DOM propre quand l'article est isolé (ex: premier + seul article, edge case rare).
3. **BlogCard se charge du rendu visuel** — pas de classe hover ici, BlogCard gère son propre hover (principe DRY).
4. **Pas d'interpolation `{title}` directe dans `aria-label`** — BlogCard a déjà son propre `aria-label` interpolé via `a11y.blogPrev` / `a11y.blogNext`. Le `<nav>` wrapper a un label plus générique pour éviter la redondance.
5. **Auto-import Nuxt** : BlogCard est dans `app/components/` donc auto-importé sans ligne `import`.
6. **Type `SurroundArticle`** : sous-ensemble de BlogArticle car `queryCollectionItemSurroundings` retourne uniquement les `fields` demandés (path, title, description, date, image, minutes). Déclaré localement pour ne pas créer un shared-types file dans cette phase.
  </action>
  <verify>
    <automated>test -f app/components/BlogPrevNext.vue && grep -c "<BlogCard" app/components/BlogPrevNext.vue</automated>
  </verify>
  <acceptance_criteria>
    - `test -f app/components/BlogPrevNext.vue` retourne 0
    - `grep -c "<BlogCard" app/components/BlogPrevNext.vue` retourne 2 (prev + next)
    - `grep "variant=\"compact\"" app/components/BlogPrevNext.vue` retourne 2+ matches
    - `grep "direction=\"prev\"" app/components/BlogPrevNext.vue` retourne 1 match
    - `grep "direction=\"next\"" app/components/BlogPrevNext.vue` retourne 1 match
    - `grep -c "prev: SurroundArticle | null" app/components/BlogPrevNext.vue` retourne 1
    - `grep -c "next: SurroundArticle | null" app/components/BlogPrevNext.vue` retourne 1
    - `grep "v-else aria-hidden=\"true\"" app/components/BlogPrevNext.vue` retourne 2 matches (D-13 empty cells)
    - `grep "grid md:grid-cols-2 gap-5" app/components/BlogPrevNext.vue` retourne 1 match
    - `grep "mt-16" app/components/BlogPrevNext.vue` retourne 1 match (spacing avant prev/next)
    - `pnpm typecheck` passe
    - `pnpm lint` passe
  </acceptance_criteria>
  <done>
BlogPrevNext.vue créé. Wrapper `<nav>` conditionnel si au moins un voisin. Grid 2 cols md. 2 BlogCard variant=compact avec direction prev/next. Cellules vides préservées pour alignement (D-13).
  </done>
</task>

<task type="auto" tdd="false">
  <name>Task 4.3 : Enrichir app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround + prev/next)</name>
  <files>app/pages/blog/[slug].vue</files>
  <read_first>
    - app/pages/blog/[slug].vue (état actuel Phase 5 : 34 lignes, query + prose wrapper — à enrichir, pas réécrire entièrement la logique)
    - app/components/BlogToc.vue (créé Task 4.1 — interface TocLink props)
    - app/components/BlogPrevNext.vue (créé Task 4.2 — interface Props prev/next)
    - app/components/BlogCard.vue (créé Wave 2 — utilisé indirectement via BlogPrevNext)
    - .planning/phases/06-blog-pages/06-UI-SPEC.md (§Article header contract lignes 280-291 pour l'ordre vertical + §Layout responsive article lignes 294-305 pour la grille desktop)
    - .planning/phases/06-blog-pages/06-RESEARCH.md (§Code Examples page article lignes 711-830 pour le skeleton complet + §Pitfall 3 watch locale + §Pitfall 4 surround mapping)
    - .planning/phases/06-blog-pages/06-PATTERNS.md (§app/pages/blog/[slug].vue lignes 107-148 pour les patterns et gotchas)
    - .planning/phases/06-blog-pages/06-CONTEXT.md (D-05 TOC layout, D-07 header complet, D-08 max-w-3xl prose, D-11 surround helper, D-12 order DESC, D-13 edges)
    - i18n/locales/fr.json (confirmer blog.breadcrumb.home/blog, blog.readingTime, a11y.blogTocToggle existent)
  </read_first>
  <action>
Réécrire substantiellement `app/pages/blog/[slug].vue` pour passer du minimal (Phase 5) au chrome complet (Phase 6). Garder le squelette de query `queryCollection('blog_fr').path(path).first()` mais :
1. Convertir `isFr` en `computed` (réactivité switch locale — Pitfall 3 corrigé)
2. Ajouter `{ watch: [locale] }` sur useAsyncData
3. Ajouter une 2e useAsyncData pour `queryCollectionItemSurroundings` avec fields explicites + where draft + order date DESC
4. Construire `breadcrumbItems` computed (Accueil/Home + Blog + titre article)
5. Construire `formattedDate` computed avec `Intl.DateTimeFormat`
6. Mapper `prevArticle = surround[1]` et `nextArticle = surround[0]` (Pitfall 4)
7. Restructurer le template : UBreadcrumb + H1 + meta row + tags + cover image + grid layout (article + TOC aside) + BlogPrevNext

**Fichier complet :**

```vue
<script setup lang="ts">
const { t, locale } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const isFr = computed(() => locale.value === 'fr')
const slug = route.params.slug as string
const path = computed(() => (isFr.value ? `/fr/blog/${slug}` : `/en/blog/${slug}`))

// 1) Article principal (PAS de filtre draft : URL directe accessible même si draft — D-14)
const { data: page } = await useAsyncData(
  `blog-${locale.value}-${slug}`,
  () =>
    isFr.value
      ? queryCollection('blog_fr').path(path.value).first()
      : queryCollection('blog_en').path(path.value).first(),
  { watch: [locale] },
)

if (!page.value) {
  throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
}

// 2) Surroundings (prev/next) AVEC filtre draft + order DESC
const { data: surround } = await useAsyncData(
  `blog-surround-${locale.value}-${slug}`,
  () =>
    isFr.value
      ? queryCollectionItemSurroundings('blog_fr', path.value, {
          fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
        })
          .where('draft', '=', false)
          .order('date', 'DESC')
      : queryCollectionItemSurroundings('blog_en', path.value, {
          fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
        })
          .where('draft', '=', false)
          .order('date', 'DESC'),
  { watch: [locale] },
)

// D-12 : order DESC → surround[0] = plus récent (next UI), surround[1] = plus ancien (prev UI) — Pitfall 4
const nextArticle = computed(() => surround.value?.[0] ?? null)
const prevArticle = computed(() => surround.value?.[1] ?? null)

// Breadcrumb (D-07 Accueil → Blog → Titre)
const breadcrumbItems = computed(() => [
  { label: t('blog.breadcrumb.home'), to: localePath('/'), icon: 'i-lucide-home' },
  { label: t('blog.breadcrumb.blog'), to: localePath('/blog') },
  { label: page.value?.title ?? '' },
])

// Date formattée i18n (Intl.DateTimeFormat — style long)
const formattedDate = computed(() => {
  if (!page.value?.date) return ''
  try {
    return new Intl.DateTimeFormat(isFr.value ? 'fr-FR' : 'en-US', {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
    }).format(new Date(page.value.date))
  } catch {
    return page.value.date
  }
})

// Reading time avec fallback composable si minutes non injecté
const readingMinutes = computed(() => {
  if (typeof page.value?.minutes === 'number') return page.value.minutes
  return useReadingTime(page.value?.description ?? '')
})

// TOC links (safe access — page.body.toc peut être undefined pour un article sans heading)
const tocLinks = computed(() => {
  // @ts-expect-error — @nuxt/content v3 body shape type 'minimal' n'expose pas toc dans les types
  return (page.value?.body?.toc?.links as Array<{ id: string; depth: number; text: string; children?: unknown[] }> | undefined) ?? []
})

// SEO minimal Phase 6 — Phase 7 enrichira (JSON-LD Article, og:image, BreadcrumbList)
useSeoMeta({
  title: () => page.value?.title,
  description: () => page.value?.description,
  ogTitle: () => page.value?.title,
  ogDescription: () => page.value?.description,
  ogType: 'article',
})
</script>

<template>
  <div class="max-w-7xl mx-auto px-4 py-12">
    <!-- Breadcrumb (D-07 au-dessus du H1) -->
    <UBreadcrumb :items="breadcrumbItems" class="mb-6 text-sm" />

    <!-- Layout grid desktop: article + TOC aside sticky -->
    <div class="lg:grid lg:grid-cols-[1fr_16rem] lg:gap-12">
      <!-- Main column -->
      <div class="max-w-3xl mx-auto lg:mx-0 w-full">
        <!-- Header (D-07 ordre exact) -->
        <header class="mb-8">
          <!-- H1 -->
          <h1 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
            {{ page?.title }}
          </h1>

          <!-- Meta row: date + · + reading time + TOC button (mobile only) -->
          <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
            <time :datetime="page?.date" class="font-mono inline-flex items-center gap-1.5">
              <UIcon name="i-lucide-calendar" class="w-3.5 h-3.5" />
              {{ formattedDate }}
            </time>
            <span aria-hidden="true">·</span>
            <span class="inline-flex items-center gap-1.5">
              <UIcon name="i-lucide-clock" class="w-3.5 h-3.5" />
              {{ t('blog.readingTime', { minutes: readingMinutes }) }}
            </span>
            <!-- Mobile TOC trigger — sur lg+, le BlogToc rend son propre trigger hidden par sa branche lg:hidden -->
          </div>

          <!-- Tags row -->
          <div v-if="page?.tags?.length" class="flex flex-wrap gap-2 mb-6">
            <UBadge
              v-for="tag in page.tags"
              :key="tag"
              color="primary"
              variant="subtle"
            >
              {{ tag }}
            </UBadge>
          </div>

          <!-- Cover image hero (si frontmatter.image — D-07) -->
          <NuxtImg
            v-if="page?.image"
            :src="page.image"
            :alt="page.title"
            loading="eager"
            format="webp"
            class="w-full aspect-[21/9] object-cover rounded-2xl mt-2 mb-4"
          />
        </header>

        <!-- Body markdown (prose hérité Phase 5 inchangé) -->
        <article class="prose dark:prose-invert max-w-none">
          <ContentRenderer v-if="page" :value="page" />
        </article>

        <!-- Prev/Next en bas de la colonne principale -->
        <BlogPrevNext :prev="prevArticle" :next="nextArticle" />
      </div>

      <!-- TOC aside (desktop sticky, mobile drawer par trigger interne au composant) -->
      <BlogToc v-if="tocLinks.length > 0" :links="tocLinks" />
    </div>
  </div>
</template>
```

**Notes d'implémentation :**

1. **Le trigger TOC mobile vit dans BlogToc.vue** (sa branche `<div class="lg:hidden inline-block">` avec UButton). Il N'est PAS placé dans la meta row de [slug].vue — architecture unifiée, BlogToc gère desktop ET mobile. BlogToc se rend dans la grid desktop à droite ET contient son propre UButton mobile qui apparaît sur < lg. Au résultat : le composant BlogToc se rend côté DOM à la bonne position logique, et ses media queries gèrent la visibilité.

2. **`@ts-expect-error` sur tocLinks** : @nuxt/content v3 expose bien `body.toc` au runtime mais le type exporté est `'minimal'` (tuples) qui ne le déclare pas statiquement. L'accès est safe au runtime, on commente le contournement TS.

3. **Cover image `loading="eager"`** (pas `lazy`) : c'est le hero image above-the-fold de l'article, chargement immédiat pour LCP. Opposite de BlogCard listing (lazy).

4. **max-w-3xl conservé sur la colonne article** (D-08) : sur desktop, la colonne gauche de la grid est `1fr` mais le contenu interne est `max-w-3xl` pour la lisibilité prose. Le wrapping `lg:mx-0` évite qu'il se re-centre mal quand la TOC occupe la colonne droite.

5. **Query draft filter asymétrie** : la query article principale n'a PAS `.where('draft', '=', false)` — cela permet d'accéder aux drafts par URL directe (D-14). En revanche, la query surround A le filtre — les drafts ne peuplent jamais la navigation prev/next. Cette asymétrie est INTENTIONNELLE.

6. **Cas test actuel** : `/fr/blog/test-kotlin-syntax` (draft:true) s'ouvre, UBreadcrumb + header + body + TOC visibles. Mais BlogPrevNext sera vide (`prev=null, next=null`) car c'est le seul article et il est draft. Le `<nav v-if="prev || next">` ne rend rien — visuellement propre.

7. **`createError` 404** : conservé depuis Phase 5, pas d'UI custom — `error.vue` layout global du projet prend le relais.

8. **`ogType: 'article'`** ajouté (était `website` dans Phase 5 implicite) — Phase 7 enrichira encore avec `articleAuthor`, `articlePublishedTime`, etc.
  </action>
  <verify>
    <automated>grep -c "queryCollectionItemSurroundings" app/pages/blog/[slug].vue && grep -c "UBreadcrumb" app/pages/blog/[slug].vue && grep -c "<BlogToc" app/pages/blog/[slug].vue && grep -c "<BlogPrevNext" app/pages/blog/[slug].vue  </verify>
  <acceptance_criteria>
    - `grep -c "queryCollectionItemSurroundings" app/pages/blog/\[slug\].vue` retourne 2 (une par branche FR/EN)
    - `grep -c "UBreadcrumb" app/pages/blog/\[slug\].vue` retourne 1+ match
    - `grep -c "<BlogToc" app/pages/blog/\[slug\].vue` retourne 1 match
    - `grep -c "<BlogPrevNext" app/pages/blog/\[slug\].vue` retourne 1 match
    - `grep -c "queryCollection('blog_fr')" app/pages/blog/\[slug\].vue` retourne 1 (article principal)
    - `grep -c "queryCollection('blog_en')" app/pages/blog/\[slug\].vue` retourne 1
    - `grep -c "isFr = computed" app/pages/blog/\[slug\].vue` retourne 1 (Pitfall 3 corrigé — réactif)
    - `grep -c "watch: \[locale\]" app/pages/blog/\[slug\].vue` retourne 2 (article + surround)
    - `grep "\.where('draft', '=', false)" app/pages/blog/\[slug\].vue` retourne 2+ matches (surround FR + EN, PAS sur la query path().first())
    - `grep "\.order('date', 'DESC')" app/pages/blog/\[slug\].vue` retourne 2 matches
    - `grep -c "nextArticle = computed" app/pages/blog/\[slug\].vue` retourne 1
    - `grep -c "prevArticle = computed" app/pages/blog/\[slug\].vue` retourne 1
    - `grep "surround.value?\[0\]" app/pages/blog/\[slug\].vue` retourne 1 match (Next = [0] per Pitfall 4)
    - `grep "surround.value?\[1\]" app/pages/blog/\[slug\].vue` retourne 1 match (Prev = [1])
    - `grep -c "breadcrumbItems" app/pages/blog/\[slug\].vue` retourne 2+ matches (computed + bind)
    - `grep -c "Intl.DateTimeFormat" app/pages/blog/\[slug\].vue` retourne 1
    - `grep -c "t('blog.breadcrumb.home')" app/pages/blog/\[slug\].vue` retourne 1
    - `grep -c "t('blog.breadcrumb.blog')" app/pages/blog/\[slug\].vue` retourne 1
    - `grep -c "t('blog.readingTime'" app/pages/blog/\[slug\].vue` retourne 1
    - `grep -c "ContentRenderer" app/pages/blog/\[slug\].vue` retourne 1 (body markdown préservé de Phase 5)
    - `grep "prose dark:prose-invert max-w-none" app/pages/blog/\[slug\].vue` retourne 1 match (wrapper Phase 5 intact)
    - `grep "aspect-\[21/9\]" app/pages/blog/\[slug\].vue` retourne 1 match (cover hero aspect)
    - `grep "lg:grid-cols-\[1fr_16rem\]" app/pages/blog/\[slug\].vue` retourne 1 match (grid desktop D-08)
    - `grep "max-w-3xl mx-auto lg:mx-0" app/pages/blog/\[slug\].vue` retourne 1 match (colonne article lisibilité)
    - `grep -c "loading=\"eager\"" app/pages/blog/\[slug\].vue` retourne 1 (cover hero above-fold)
    - `grep "createError" app/pages/blog/\[slug\].vue` retourne 1 match (404 handler Phase 5 préservé)
    - `pnpm typecheck` passe (attendu : zero nouvelle erreur, `@ts-expect-error` documenté sur page.body.toc)
    - `pnpm lint` passe
    - `pnpm build` complète (SSR prerender OK)
    - Tests runtime (`pnpm dev`) :
      - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "Accueil"` >= 1 (breadcrumb)
      - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "Guide du format Markdown"` >= 1 (H1 titre)
      - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (body markdown rendu SSR)
      - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "min de lecture"` >= 1 (reading time i18n)
      - `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -ci "Home"` >= 1 (breadcrumb EN)
      - `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -ci "min read"` >= 1
      - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Sommaire"` >= 1 (TOC title FR — présent car l'article a des headings h2)
      - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax` ne contient PAS "Article précédent" ni "Article suivant" en HTML (car seul article draft → BlogPrevNext ne rend pas le `<nav>`)
  </acceptance_criteria>
  <done>
app/pages/blog/[slug].vue enrichi. Query article principale conservée sans filtre draft (URL directe accessible D-14). 2e useAsyncData avec queryCollectionItemSurroundings + filtre draft + order DESC. isFr computed + watch locale corrigent Pitfall 3. Breadcrumb + H1 + meta row + tags + cover hero (aspect-21/9) + ContentRenderer (prose Phase 5 inchangé) + BlogPrevNext. BlogToc integré dans grid desktop (sticky aside) + trigger mobile auto. Mapping prev=[1]/next=[0] respecte Pitfall 4. Typecheck + lint + build verts.
  </done>
</task>

</tasks>

<verification>
1. `pnpm typecheck` passe (zero nouvelle erreur)
2. `pnpm lint` passe
3. `pnpm build` complète (validation SSR + prerender)
4. Tests SSR `pnpm dev` :
   - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (body markdown rendu côté serveur, pas SPA shell — Success criterion 2)
   - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Sommaire"` >= 1 OU (TOC title FR présent — Success criterion 3 : TOC générée depuis page.body.toc)
   - `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Accueil"` >= 1 (breadcrumb rendu SSR)
   - `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (version EN)
5. Tests interactifs (navigateur) :
   - Scroll dans l'article → heading TOC actif change de surlignage (brand-500) au passage dans la zone 20%-70%
   - Viewport < lg (narrow) : UButton "Sommaire" dans la meta row ; clic → UDrawer s'ouvre à droite avec la TOC ; clic sur un item ferme le drawer
   - Switch FR/EN via AppHeader toggle : breadcrumb, H1, date, tags, reading time se re-rendent dans la nouvelle langue
6. Success criteria Phase 6 globaux (TOUS validés à la fin de Wave 3) :
   - ✓ curl /fr/blog → HTML SSR listing (Plan 03 Success criterion 1)
   - ✓ curl /fr/blog/[slug] → article rendu SSR complet (Plan 04 Success criterion 2)
   - ✓ TOC visible depuis headings (Success criterion 3)
   - ✓ Liens prev/next présents quand voisins existent (Success criterion 4 — à valider en Phase 8 quand articles seed ajoutés)
   - ✓ curl /en/blog → listing EN (Plan 03 Success criterion 5)
</verification>

<success_criteria>
- BlogToc.vue créé : sticky desktop + UDrawer mobile + IntersectionObserver (rootMargin '-20% 0px -70% 0px')
- BlogPrevNext.vue créé : grid 2 cols de BlogCard variant=compact, cellules vides préservées (D-13)
- [slug].vue enrichi : UBreadcrumb + H1 + meta row (date formatée + reading time) + tags + cover hero + body prose (Phase 5 intact) + BlogToc + BlogPrevNext
- isFr converti en computed, watch locale sur les 2 useAsyncData (Pitfall 3)
- queryCollectionItemSurroundings avec littéraux + where draft + order DESC (Pitfalls 1 + 4)
- Mapping prev=surround[1] / next=surround[0] (Pitfall 4 documenté dans commentaires code)
- Typecheck + lint + build verts
- curl /fr/blog/[slug] et /en/blog/[slug] retournent HTML SSR complet incluant breadcrumb/H1/body/TOC
</success_criteria>

<output>
After completion, create `.planning/phases/06-blog-pages/06-04-SUMMARY.md` with:
- Commandes curl exécutées + extraits HTML (preuve SSR breadcrumb + body + TOC)
- Validation manuelle TOC highlight au scroll (desktop + mobile drawer)
- Validation manuelle switch FR/EN sur l'article
- Mapping surround[0]/surround[1] validé empiriquement (ajouter un 2e article non-draft temporaire si besoin pour le test, puis le supprimer)
- Any deviation (ex: UDrawer prop name 'direction' vs 'side' — selon la version Nuxt UI installée)
- Checklist success criteria Phase 6 — cocher les 5 à la fin
</output>
