edf7593f4f
Phase 6 Blog Pages decomposed into: - 06-01 (Wave 1): content schema + reading-time Nitro hook + draft flags - 06-02 (Wave 2): i18n keys + AppHeader link + BlogCard unified - 06-03 (Wave 3): listing page /blog SSR bilingue - 06-04 (Wave 3): [slug] enrichment + BlogToc + BlogPrevNext Plans 06-03 and 06-04 have zero file overlap and run in parallel. Covers BLOG-02, BLOG-03, BLOG-06. Honors all 21 D-XX user decisions from 06-CONTEXT.md. Phase 5 gotchas (literal queryCollection, single [slug].vue, no routeRules /blog/**) respected in every query branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
788 lines
38 KiB
Markdown
788 lines
38 KiB
Markdown
---
|
|
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>
|