Files
portfolio/.planning/phases/06-blog-pages/06-02-PLAN.md
T
kayjaydee edf7593f4f docs(06): create phase plan (4 plans, 3 waves)
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>
2026-04-22 01:09:25 +02:00

29 KiB

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 02 execute 2
i18n/locales/fr.json
i18n/locales/en.json
app/components/layout/AppHeader.vue
app/components/BlogCard.vue
true
BLOG-02
BLOG-03
BLOG-06
blog
i18n
nav
blog-card
shared-components
truths artifacts key_links
Les clés i18n `nav.blog`, `blog.title`, `blog.subtitle`, `blog.stats.*`, `blog.readingTime`, `blog.prevArticle`, `blog.nextArticle`, `blog.backToBlog`, `blog.toc.title`, `blog.emptyState.*`, `blog.breadcrumb.*`, `a11y.blogTocToggle`, `a11y.blogPrev`, `a11y.blogNext` existent dans fr.json ET en.json avec des valeurs traduites
AppHeader.vue affiche un lien `Blog` entre Hytale et Projects dans la nav desktop ET mobile
BlogCard.vue est un composant unique avec variant prop `default` (listing) et `compact` (prev/next), importable partout via auto-import
BlogCard variant default rend : cover image conditionnelle (si article.image) aspect-16/9 + titre + description line-clamp-2 + date formatée i18n + premier tag UBadge + reading time
BlogCard variant compact rend : pas d'image + label row 'Article précédent/suivant' + icône arrow + titre + date, utilisé exclusivement par BlogPrevNext en Wave 3
path provides contains
i18n/locales/fr.json Clés blog.* + nav.blog + a11y.blog* en français "blog":
path provides contains
i18n/locales/en.json Clés blog.* + nav.blog + a11y.blog* en anglais "blog":
path provides contains
app/components/layout/AppHeader.vue Nav link Blog entre hytale et projects (desktop + mobile) { key: 'blog', path: '/blog' }
path provides exports_default
app/components/BlogCard.vue Composant unifié variant default + compact pour listing et prev/next true
from to via pattern
app/components/BlogCard.vue i18n blog.readingTime / blog.prevArticle / blog.nextArticle t('blog.readingTime', { minutes }) dans le template t('blog.(readingTime|prevArticle|nextArticle)'
from to via pattern
app/components/layout/AppHeader.vue i18n nav.blog t(`nav.${link.key}`) avec key 'blog' ajoutée dans navLinks key: 'blog'
from to via pattern
app/components/BlogCard.vue NuxtLink localePath('/blog/' + slug) absolute inset-0 SEO link pattern de ProjectCard localePath
Poser les **3 pré-requis transverses** consommés par les deux pages blog (Wave 3) : 1. Les clés i18n dans fr.json + en.json (sans elles, tout template de Wave 3 rendera des `{{ $t(...) }}` vides) 2. Le lien nav `Blog` dans AppHeader (sans lui, la nav ne mène pas au blog — rupture de découvrabilité D-15) 3. Le composant `BlogCard.vue` unifié (sans lui, ni le listing ni la section prev/next ne peuvent rendre quoi que ce soit — D-20 exige composant unique avec variant)

Purpose: Les 3 tâches de ce plan sont indépendantes les unes des autres (fichiers disjoints) mais nécessaires ENSEMBLE avant que Wave 3 (pages) puisse être exécutée. Elles forment la "couche composition partagée".

Output:

  • i18n/locales/fr.json + en.json : bloc blog.* complet + nav.blog + 3 clés a11y.blog*
  • app/components/layout/AppHeader.vue : entrée { key: 'blog', path: '/blog' } ajoutée dans navLinks entre hytale et projects
  • app/components/BlogCard.vue : composant variant default + compact, auto-importé par Nuxt

<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/components/ProjectCard.vue @app/components/layout/AppHeader.vue @i18n/locales/fr.json @i18n/locales/en.json ```typescript interface BlogArticle { path: string // ex: '/fr/blog/my-slug' title: string description: string date: string tags?: string[] image?: string draft?: boolean // ajouté Wave 1 wordCount?: number // ajouté Wave 1 (via hook) minutes?: number // ajouté Wave 1 (via hook) } ```
interface BlogCardProps {
  article: BlogArticle  // ou un sous-ensemble pour variant compact (fields prop de surround())
  variant?: 'default' | 'compact'
  direction?: 'prev' | 'next'  // requis seulement si variant='compact'
}
<article class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm overflow-hidden transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5">
  <!-- Cover image + padding p-5 sm:p-6 + tag badge + date + title + description -->
  <!-- NuxtLink absolute inset-0 pour SEO + a11y -->
</article>
const navLinks = computed(() => [
  { key: 'home', path: '/' },
  { key: 'hytale', path: '/hytale' },
  { key: 'projects', path: '/projects' },
  { key: 'about', path: '/about' },
  { key: 'contact', path: '/contact' },
  { key: 'fiverr', path: '/fiverr' },
])

Le template itère via v-for="link in navLinks" puis {{ t(\nav.${link.key}`) }}` — ajouter une entrée propage automatiquement au desktop ET au mobile slideover.

Task 2.1 : Ajouter le bloc complet `blog.*` + `nav.blog` + `a11y.blog*` dans fr.json et en.json - i18n/locales/fr.json - i18n/locales/en.json - i18n/locales/fr.json (structure actuelle : "nav" en haut, "footer", "a11y", "seo", "projects" — pour insérer "blog" en suivant la convention de clés top-level du projet) - i18n/locales/en.json (mêmes clés, version EN) - .planning/phases/06-blog-pages/06-UI-SPEC.md (§Copywriting Contract lignes 115-172 pour les libellés FR/EN EXACTS + §i18n Keys à créer lignes 339-379 pour la structure JSON complète) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-21) - .planning/phases/06-blog-pages/06-PATTERNS.md (§i18n/locales lignes 464-482 — convention "bloc projects utilise les accents, suivre ce pattern, pas a11y/seo qui sont sans accents") **Pour `i18n/locales/fr.json` :**
  1. Dans le bloc existant "nav": { ... } (lignes 2-9), ajouter une nouvelle clé "blog" avec la valeur "Blog". Placer logiquement avant "projects" pour refléter l'ordre de navigation (hytale → blog → projects), mais l'ordre dans le JSON n'impacte pas le runtime — l'important est la présence de la clé.

  2. Dans le bloc existant "a11y": { ... } (lignes 23-34), ajouter 3 nouvelles clés à la fin du bloc :

"blogTocToggle": "Afficher le sommaire",
"blogPrev": "Article précédent : {title}",
"blogNext": "Article suivant : {title}"
  1. Ajouter un NOUVEAU bloc top-level "blog": { ... } (à placer après le bloc "projects" pour cohérence thématique, ou à la fin du fichier — l'emplacement est au jugement de l'exécutant tant que le JSON reste valide) contenant :
"blog": {
  "title": "Blog",
  "subtitle": "Articles techniques, retours d'expérience et guides pratiques sur le développement de plugins Hytale et l'écosystème web.",
  "stats": {
    "articles": "Articles",
    "tags": "Tags",
    "languages": "Langues"
  },
  "readingTime": "{minutes} min de lecture",
  "prevArticle": "Article précédent",
  "nextArticle": "Article suivant",
  "backToBlog": "Retour au blog",
  "toc": {
    "title": "Sommaire"
  },
  "emptyState": {
    "title": "Bientôt des articles Hytale",
    "description": "Le blog se prépare. Les premiers articles sur le développement de plugins Hytale arrivent bientôt.",
    "cta": "Me contacter"
  },
  "breadcrumb": {
    "home": "Accueil",
    "blog": "Blog"
  }
}

Pour i18n/locales/en.json :

Mêmes additions, traductions EN :

  1. nav.blog = "Blog"

  2. a11y.blog* :

"blogTocToggle": "Show table of contents",
"blogPrev": "Previous article: {title}",
"blogNext": "Next article: {title}"
  1. Bloc blog :
"blog": {
  "title": "Blog",
  "subtitle": "Technical articles, experience feedback and practical guides on Hytale plugin development and the web ecosystem.",
  "stats": {
    "articles": "Articles",
    "tags": "Tags",
    "languages": "Languages"
  },
  "readingTime": "{minutes} min read",
  "prevArticle": "Previous article",
  "nextArticle": "Next article",
  "backToBlog": "Back to blog",
  "toc": {
    "title": "Table of contents"
  },
  "emptyState": {
    "title": "Hytale articles coming soon",
    "description": "The blog is being prepared. The first articles on Hytale plugin development are coming soon.",
    "cta": "Contact me"
  },
  "breadcrumb": {
    "home": "Home",
    "blog": "Blog"
  }
}

Conventions à respecter :

  • Accents : FR utilise les accents dans le bloc blog.* (comme le bloc projects existant), PAS le pattern ASCII des blocs a11y/seo. Ex: "Bientôt" avec ô, "précédent" avec é, "sommaire" — accentués. Cohérent avec PATTERNS.md §convention.
  • Interpolation : {minutes} et {title} sont la syntaxe vue-i18n standard (pas {{ minutes }}, pas %{minutes}). Cette syntaxe est déjà utilisée dans le projet (ex: à vérifier dans les blocs existants).
  • Valid JSON : ne PAS laisser de virgule traînante après la dernière clé d'un bloc (JSON strict).
  • Ordre des blocs : ne pas réorganiser les blocs existants (nav, footer, a11y, seo, projects, home, about, etc.) — uniquement ajouter. node -e "const fr=require('./i18n/locales/fr.json'); const en=require('./i18n/locales/en.json'); console.log(fr.nav.blog, en.nav.blog, fr.blog.title, en.blog.title, fr.blog.readingTime, en.blog.readingTime, fr.a11y.blogTocToggle, en.a11y.blogTocToggle)" <acceptance_criteria>
    • node -e "console.log(require('./i18n/locales/fr.json').nav.blog)" affiche Blog
    • node -e "console.log(require('./i18n/locales/en.json').nav.blog)" affiche Blog
    • node -e "console.log(require('./i18n/locales/fr.json').blog.title)" affiche Blog
    • node -e "console.log(require('./i18n/locales/fr.json').blog.subtitle)" commence par Articles techniques
    • node -e "console.log(require('./i18n/locales/en.json').blog.subtitle)" commence par Technical articles
    • node -e "console.log(require('./i18n/locales/fr.json').blog.readingTime)" affiche {minutes} min de lecture
    • node -e "console.log(require('./i18n/locales/en.json').blog.readingTime)" affiche {minutes} min read
    • node -e "console.log(require('./i18n/locales/fr.json').blog.emptyState.cta)" affiche Me contacter
    • node -e "console.log(require('./i18n/locales/en.json').blog.emptyState.cta)" affiche Contact me
    • node -e "console.log(require('./i18n/locales/fr.json').blog.toc.title)" affiche Sommaire
    • node -e "console.log(require('./i18n/locales/en.json').blog.toc.title)" affiche Table of contents
    • node -e "console.log(require('./i18n/locales/fr.json').a11y.blogTocToggle)" affiche Afficher le sommaire
    • node -e "console.log(require('./i18n/locales/fr.json').a11y.blogPrev)" contient {title}
    • node -e "console.log(require('./i18n/locales/fr.json').blog.breadcrumb.home)" affiche Accueil
    • node -e "console.log(require('./i18n/locales/en.json').blog.breadcrumb.home)" affiche Home
    • node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/fr.json'))" ne throw pas (JSON valide)
    • node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/en.json'))" ne throw pas
    • Les clés existantes (nav.home, nav.hytale, seo.*, projects.*) sont inchangées — vérifier par node -e "console.log(require('./i18n/locales/fr.json').nav.hytale)" = Hytale </acceptance_criteria> Les deux fichiers i18n contiennent nav.blog, 3 clés a11y.blog*, et un bloc blog.* complet avec les 14 clés listées (title, subtitle, stats.articles/tags/languages, readingTime, prevArticle, nextArticle, backToBlog, toc.title, emptyState.title/description/cta, breadcrumb.home/blog). JSON valide. Aucune clé existante modifiée.
Task 2.2 : Ajouter le lien Blog dans AppHeader.vue navLinks (entre hytale et projects) app/components/layout/AppHeader.vue - app/components/layout/AppHeader.vue (état actuel : navLinks lignes 8-15, template desktop lignes 44-55, slideover mobile lignes 89-100 — le template itère via v-for donc UN SEUL changement suffit) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-15 : ordre final Home / Hytale / **Blog** / Projects / About / Contact / Fiverr) - .planning/phases/06-blog-pages/06-PATTERNS.md (§AppHeader lignes 431-460) Dans `app/components/layout/AppHeader.vue`, modifier UNIQUEMENT l'array `navLinks` computed (lignes 8-15). Insérer `{ key: 'blog', path: '/blog' }` entre l'entrée `hytale` et l'entrée `projects`.

Avant :

const navLinks = computed(() => [
  { key: 'home', path: '/' },
  { key: 'hytale', path: '/hytale' },
  { key: 'projects', path: '/projects' },
  { key: 'about', path: '/about' },
  { key: 'contact', path: '/contact' },
  { key: 'fiverr', path: '/fiverr' },
])

Après :

const navLinks = computed(() => [
  { key: 'home', path: '/' },
  { key: 'hytale', path: '/hytale' },
  { key: 'blog', path: '/blog' },
  { key: 'projects', path: '/projects' },
  { key: 'about', path: '/about' },
  { key: 'contact', path: '/contact' },
  { key: 'fiverr', path: '/fiverr' },
])

Ne toucher à RIEN d'autre dans le fichier :

  • Pas de modification du template (le v-for="link in navLinks" prend l'array updated automatiquement)
  • Pas de modification du slideover mobile (même v-for sur la même source)
  • Pas de modification des imports, des refs, des fonctions isActive/toggleLocale/toggleTheme
  • Ne pas ajouter un bloc blog dédié — passer par le pattern itératif existant est intentionnel (cohérence visuelle + moins de code)

Pourquoi path: '/blog' (pas /fr/blog) : le template wrap localePath(link.path) dans le NuxtLink :to (ligne 46 et 91). localePath('/blog') résout automatiquement vers /fr/blog ou /en/blog selon la locale active — pattern i18n existant respecté.

Pourquoi la clé 'blog' : le template interpole {{ t(\nav.${link.key}`) }}— la clénav.blogajoutée par Task 2.1 sera automatiquement utilisée, pas de hardcode. </action> <verify> <automated>grep -c "{ key: 'blog', path: '/blog' }" app/components/layout/AppHeader.vue</automated> </verify> <acceptance_criteria> -grep -c "{ key: 'blog', path: '/blog' }" app/components/layout/AppHeader.vueretourne 1 -grep -n "key: 'hytale'" app/components/layout/AppHeader.vueretourne une ligne (ex: ligne 10) -grep -n "key: 'blog'" app/components/layout/AppHeader.vueretourne une ligne (ex: ligne 11) -grep -n "key: 'projects'" app/components/layout/AppHeader.vueretourne une ligne (ex: ligne 12) - Le numéro de ligne dekey: 'blog'est STRICTEMENT entre celui dekey: 'hytale'etkey: 'projects'(ordre respecté D-15) -grep -c "key: 'home'" app/components/layout/AppHeader.vueretourne 1 (pas de duplication/suppression) -grep -c "key: 'fiverr'" app/components/layout/AppHeader.vueretourne 1 -grep -c "v-for="link in navLinks"" app/components/layout/AppHeader.vueretourne 2 (desktop + mobile templates intacts) -pnpm typecheckpasse -pnpm dev+ visite manuelle de/fr/montre un lienBlogentreHytaleetProjetsdans la nav desktop (validation visuelle optionnelle, non-bloquante) </acceptance_criteria> <done> AppHeader.vue contient{ key: 'blog', path: '/blog' }dans navLinks, positionné entre hytale et projects. Aucune autre modification. Template v-for inchangé, le nouveau lien apparaît automatiquement en desktop et dans le slideover mobile. Le libelléBlogvient denav.blog` (Task 2.1).

Task 2.3 : Créer app/components/BlogCard.vue (variants default + compact, D-20) app/components/BlogCard.vue - app/components/ProjectCard.vue (pattern COMPLET à transposer pour variant default : lignes 18-90 — article wrapper, NuxtImg cover, content section, NuxtLink absolute inset-0) - .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogCard variant contract lignes 213-230 pour le layout EXACT des deux variants + §Typography + §Color pour les classes) - .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogCard.vue lignes 152-252 — adaptation vs ProjectCard détaillée) - .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 6 lignes 520-556 pour la structure TypeScript + § Pitfall 6 lignes 625-629 pour le a11y SEO link) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-02 : tags non-cliquables ; D-03 : pas de fallback image ; D-10 : pas d'image en variant compact) - i18n/locales/fr.json (après Task 2.1 — confirmer que blog.readingTime / prevArticle / nextArticle sont bien présents) Créer `app/components/BlogCard.vue` avec `<script setup lang="ts">`, props typées, date formattée via `Intl.DateTimeFormat`, deux templates (variant default / compact) branchés par `v-if`. Le composant est auto-importé par Nuxt (convention `app/components/*.vue`).

Script setup complet :

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

interface Props {
  article: BlogArticle
  variant?: 'default' | 'compact'
  direction?: 'prev' | 'next' // uniquement si variant='compact'
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'default',
  direction: 'next',
})

const { t, locale } = useI18n()
const localePath = useLocalePath()

// Slug extrait du path pour construire l'URL locale-agnostique
// path = '/fr/blog/my-slug' ou '/en/blog/my-slug' → slug = 'my-slug'
const slug = computed(() => {
  const parts = props.article.path.split('/').filter(Boolean)
  return parts[parts.length - 1] ?? ''
})

const formattedDate = computed(() => {
  try {
    return new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-FR' : 'en-US', {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
    }).format(new Date(props.article.date))
  } catch {
    return props.article.date
  }
})

// Reading time avec fallback composable si minutes non injecté (ex: dev hot-reload)
const readingMinutes = computed(() => {
  if (typeof props.article.minutes === 'number') return props.article.minutes
  return useReadingTime(props.article.description ?? '')
})

const directionIcon = computed(() =>
  props.direction === 'prev' ? 'i-lucide-arrow-left' : 'i-lucide-arrow-right'
)

const directionLabel = computed(() =>
  props.direction === 'prev' ? t('blog.prevArticle') : t('blog.nextArticle')
)
</script>

Template — variant default (listing) :

Transposition directe du pattern ProjectCard.vue. Différences :

  • NuxtLink utilise localePath('/blog/' + slug) (pas /project/${id})
  • aspect-[16/9] sur l'image (pas h-52)
  • <h2> (pas <h3>) pour le titre — c'est un listing d'articles (hierarchie SEO)
  • Description line-clamp-2 (pas line-clamp-3)
  • Footer row : reading time + tags supplémentaires (+N) à la place des technologies
  • Schema.org BlogPosting (pas CreativeWork)
  • Cover image conditionnelle : uniquement si article.image présent (D-03 pas de fallback)
<template>
  <article
    v-if="variant === 'default'"
    class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm overflow-hidden transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5"
    itemscope
    itemtype="https://schema.org/BlogPosting"
  >
    <!-- Cover image (D-03 : aucun fallback si absent) -->
    <NuxtLink
      v-if="article.image"
      :to="localePath(`/blog/${slug}`)"
      class="block relative overflow-hidden"
    >
      <NuxtImg
        :src="article.image"
        :alt="article.title"
        loading="lazy"
        format="webp"
        width="400"
        height="225"
        class="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
        itemprop="image"
      />
    </NuxtLink>

    <!-- Content -->
    <div class="p-5 sm:p-6 flex flex-col gap-3">
      <!-- Tag + Date -->
      <div class="flex items-center justify-between">
        <UBadge v-if="article.tags?.[0]" color="primary" variant="subtle" itemprop="keywords">
          {{ article.tags[0] }}
        </UBadge>
        <time
          class="text-xs text-gray-400 dark:text-gray-500 font-mono"
          :datetime="article.date"
          itemprop="datePublished"
        >
          {{ formattedDate }}
        </time>
      </div>

      <!-- Title -->
      <h2
        class="text-lg font-bold text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors"
        itemprop="headline"
      >
        {{ article.title }}
      </h2>

      <!-- Description -->
      <p
        v-if="article.description"
        class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed"
        itemprop="description"
      >
        {{ article.description }}
      </p>

      <!-- Footer: reading time + extra tags -->
      <div class="flex items-center justify-between pt-2">
        <span class="text-xs text-gray-400 dark:text-gray-500 font-medium 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>
        <div v-if="article.tags && article.tags.length > 1" class="flex gap-1.5">
          <span
            v-for="tag in article.tags.slice(1, 3)"
            :key="tag"
            class="text-[11px] px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium border border-gray-200/50 dark:border-gray-700/30"
          >
            {{ tag }}
          </span>
          <span
            v-if="article.tags.length > 3"
            class="text-[11px] px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800/80 text-gray-500 font-medium border border-gray-200/50 dark:border-gray-700/30"
          >
            +{{ article.tags.length - 3 }}
          </span>
        </div>
      </div>
    </div>

    <!-- SEO + a11y: full-card clickable link (D-02 tags non-cliquables  safe per Pitfall 6) -->
    <NuxtLink
      :to="localePath(`/blog/${slug}`)"
      class="absolute inset-0 z-10"
      :aria-label="`${article.title} - ${formattedDate}`"
      itemprop="url"
    />
  </article>

  <!-- Variant compact (prev/next)  D-10 pas d'image, D-09 label row + icon -->
  <article
    v-else
    class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-5 transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5 flex flex-col gap-2"
    :class="direction === 'next' ? 'items-end text-right' : 'items-start text-left'"
  >
    <div class="inline-flex items-center gap-2 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 font-medium">
      <UIcon
        v-if="direction === 'prev'"
        :name="directionIcon"
        class="w-4 h-4 transition-transform duration-200 group-hover:-translate-x-1 group-hover:text-brand-500"
      />
      <span>{{ directionLabel }}</span>
      <UIcon
        v-if="direction === 'next'"
        :name="directionIcon"
        class="w-4 h-4 transition-transform duration-200 group-hover:translate-x-1 group-hover:text-brand-500"
      />
    </div>
    <h3 class="text-base font-bold text-gray-900 dark:text-white group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors">
      {{ article.title }}
    </h3>
    <time class="text-xs font-mono text-gray-400 dark:text-gray-500" :datetime="article.date">
      {{ formattedDate }}
    </time>
    <NuxtLink
      :to="localePath(`/blog/${slug}`)"
      class="absolute inset-0 z-10"
      :aria-label="t(direction === 'prev' ? 'a11y.blogPrev' : 'a11y.blogNext', { title: article.title })"
    />
  </article>
</template>

Décisions de conception documentées :

  • slug calculé depuis article.path : les articles @nuxt/content ont un path de forme /fr/blog/my-slug → extraire le dernier segment. Évite de réclamer un champ slug explicite dans le frontmatter.
  • Variant compact sans image (D-10 + cohérent D-03 : pas de fallback image).
  • absolute inset-0 SEO link pattern OK tant que tags restent non-cliquables (Pitfall 6 + D-02 respectés).
  • Schema.org : BlogPosting + datePublished + headline + description + keywords + url + image (prépare Phase 7 JSON-LD Article sans effort supplémentaire — tout est déjà structuré).
  • text-right sur variant=next, text-left sur prev : UX directionnelle (la flèche et le texte suivent la direction du clic). test -f app/components/BlogCard.vue && grep -c "variant === 'default'" app/components/BlogCard.vue <acceptance_criteria>
    • test -f app/components/BlogCard.vue retourne 0
    • grep -c "variant === 'default'" app/components/BlogCard.vue retourne 1 (template branche)
    • grep -c "variant?: 'default' | 'compact'" app/components/BlogCard.vue retourne 1 (type union exact)
    • grep -c "direction?: 'prev' | 'next'" app/components/BlogCard.vue retourne 1
    • grep -c "withDefaults(defineProps<Props>()" app/components/BlogCard.vue retourne 1
    • grep -c "Intl.DateTimeFormat" app/components/BlogCard.vue retourne 1
    • grep -c "t('blog.readingTime'" app/components/BlogCard.vue retourne 1
    • grep "localePath(\/blog/${slug}`)" app/components/BlogCard.vue` retourne 2+ matches (au moins 2 NuxtLink)
    • grep "useReadingTime" app/components/BlogCard.vue retourne 1+ match (fallback utilisé)
    • grep "i-lucide-arrow-left" app/components/BlogCard.vue retourne 1 match (icône prev)
    • grep "i-lucide-arrow-right" app/components/BlogCard.vue retourne 1 match (icône next)
    • grep "BlogPosting" app/components/BlogCard.vue retourne 1 match (Schema.org)
    • grep "aspect-\\[16/9\\]" app/components/BlogCard.vue retourne 1 match (ratio cover listing)
    • grep -c "a11y.blogPrev" app/components/BlogCard.vue retourne 1 (label a11y interpolé)
    • grep -c "a11y.blogNext" app/components/BlogCard.vue retourne 1
    • pnpm typecheck passe sans erreur TS
    • pnpm lint passe sans nouvelle erreur ESLint sur BlogCard.vue </acceptance_criteria> BlogCard.vue créé avec script setup TS, 2 variants (default + compact), date i18n via Intl.DateTimeFormat, reading time avec fallback useReadingTime, NuxtLink absolute inset-0 pour SEO/a11y (tags non-cliquables D-02 respecté), icônes arrow directionnelles avec translate hover. Schema.org BlogPosting markup. Auto-importé par Nuxt. Typecheck + lint verts.
1. `pnpm typecheck` passe 2. `pnpm lint` passe (pas de nouvelle erreur) 3. `pnpm dev` démarre sans erreur — le lien `Blog` apparaît dans la nav desktop après Hytale, avant Projets 4. Clic sur le lien `Blog` va vers `/fr/blog` (404 attendu à ce stade — la page sera créée Wave 3) 5. Validation JSON : `node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/fr.json')); JSON.parse(require('fs').readFileSync('./i18n/locales/en.json')); console.log('valid')"` 6. Le composant BlogCard n'est consommé nulle part à ce stade — c'est normal, il sera utilisé par les pages de Wave 3.

<success_criteria>

  • fr.json et en.json contiennent tous les blocs blog., nav.blog, a11y.blog (14+ clés ajoutées par locale)
  • AppHeader.vue a { key: 'blog', path: '/blog' } à la bonne position dans navLinks (entre hytale et projects)
  • BlogCard.vue existe, typecheck vert, supporte variant default et compact
  • Aucune régression sur les clés i18n existantes ni sur la nav existante </success_criteria>
After completion, create `.planning/phases/06-blog-pages/06-02-SUMMARY.md` with: - Diff i18n (nombre de clés ajoutées FR + EN) - Position exacte du lien blog dans navLinks (ligne du fichier) - Décisions de conception BlogCard (aspects intéressants : slug derivation, direction icons, a11y label template) - Any deviation (ex: convention accents, ordre des blocs JSON)