phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase
plan
type
wave
depends_on
files_modified
autonomous
requirements
tags
must_haves
06-blog-pages
04
execute
3
app/pages/blog/[slug].vue
app/components/BlogToc.vue
app/components/BlogPrevNext.vue
true
blog
article-chrome
toc
prev-next
intersection-observer
truths
artifacts
key_links
`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)
path
provides
contains
contains_also
min_lines
app/pages/blog/[slug].vue
Page article enrichie : breadcrumb + header + TOC layout + surround + prev/next
queryCollectionItemSurroundings
UBreadcrumb
120
path
provides
contains
contains_also
app/components/BlogToc.vue
TOC sticky desktop + UDrawer mobile + IntersectionObserver highlight
IntersectionObserver
UDrawer
path
provides
contains
app/components/BlogPrevNext.vue
Grid 2 cols de BlogCard variant compact (prev + next)
variant="compact"
from
to
via
pattern
app/pages/blog/[slug].vue
queryCollectionItemSurroundings('blog_fr'|'blog_en', path, ...)
useAsyncData secondaire avec littéraux if/else + watch locale
queryCollectionItemSurroundings
from
to
via
pattern
app/pages/blog/[slug].vue
app/components/BlogToc.vue
<BlogToc :links="page.body.toc.links" ... />
<BlogToc
from
to
via
pattern
app/pages/blog/[slug].vue
app/components/BlogPrevNext.vue
<BlogPrevNext :prev :next />
<BlogPrevNext
from
to
via
pattern
app/components/BlogToc.vue
DOM headings h2/h3 rendus par ContentRenderer
IntersectionObserver sur document.getElementById(link.id) dans onMounted
IntersectionObserver
from
to
via
pattern
app/components/BlogPrevNext.vue
app/components/BlogCard.vue
<BlogCard variant="compact" direction="prev"|"next" />
<BlogCard
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)
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_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
```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)
}
Task 4.1 : Créer app/components/BlogToc.vue (sticky desktop + UDrawer mobile + IntersectionObserver)
app/components/BlogToc.vue
- 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 + 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)
Créer `app/components/BlogToc.vue`. Le composant reçoit `links: TocLink[]` via props, gère :
- Affichage desktop : `
` 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(null)` initial (Pitfall 2 hydration mismatch)
Fichier complet :
Points critiques :
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.
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.
onBeforeUnmount cleanup — critique pour éviter memory leak au navigate entre articles (anti-pattern RESEARCH).
handleItemClick ferme le drawer mobile — UX standard quand on clique sur un lien d'ancre dans un drawer.
Pas de useState pour activeId — ref local (anti-pattern RESEARCH ligne 563).
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.
Accent color uniquement sur actif — UI-SPEC §Color §Accent §6 : text-brand-500 dark:text-brand-400. Tout le reste est gris neutre.
Client-only garantit par typeof window === 'undefined' return — défensif même si onMounted ne s'exécute que côté client.
test -f app/components/BlogToc.vue && grep -c "IntersectionObserver" app/components/BlogToc.vue && grep -c "UDrawer" app/components/BlogToc.vue
<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>
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.
Task 4.2 : Créer app/components/BlogPrevNext.vue (grid 2 cols de BlogCard compact)
app/components/BlogPrevNext.vue
- 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)
Créer `app/components/BlogPrevNext.vue`. Wrapper `` 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 :
Points critiques :
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.
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).
BlogCard se charge du rendu visuel — pas de classe hover ici, BlogCard gère son propre hover (principe DRY).
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.
Auto-import Nuxt : BlogCard est dans app/components/ donc auto-importé sans ligne import.
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.
test -f app/components/BlogPrevNext.vue && grep -c "<BlogCard" app/components/BlogPrevNext.vue
<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>
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).
Task 4.3 : Enrichir app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround + prev/next)
app/pages/blog/[slug].vue
- 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)
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 :
< 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 :
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é.
@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.
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).
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.
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.
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.
createError 404 : conservé depuis Phase 5, pas d'UI custom — error.vue layout global du projet prend le relais.
ogType: 'article' ajouté (était website dans Phase 5 implicite) — Phase 7 enrichira encore avec articleAuthor, articlePublishedTime, etc.
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
<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>
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.
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 "= 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 "= 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)
<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>
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