Files
Portfolio-Game/docs/implementation-artifacts/2-7-composant-dialogue-pnj.md
skycel ec1ae92799 🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 02:08:56 +01:00

18 KiB

Story 2.7: Composant Dialogue PNJ

Status: ready-for-dev

Story

As a visiteur, I want lire les témoignages comme des dialogues de personnages style Zelda, so that l'expérience est immersive et mémorable.

Acceptance Criteria

  1. Given le composant DialoguePNJ est implémenté When il reçoit les données d'un témoignage en props Then l'avatar du PNJ s'affiche à gauche avec un style illustratif
  2. And une bulle de dialogue s'affiche à droite avec le texte
  3. And l'effet typewriter fait apparaître le texte lettre par lettre
  4. And un clic ou appui sur Espace accélère l'animation typewriter (x3-x5)
  5. And la personnalité du PNJ influence le style visuel de la bulle (sage, sarcastique, enthousiaste, professionnel)
  6. And la police serif narrative est utilisée pour le texte du dialogue
  7. And prefers-reduced-motion affiche le texte complet instantanément
  8. And le texte complet est accessible via aria-label pour les screen readers
  9. And une navigation entre témoignages est disponible (précédent/suivant)
  10. And une transition animée s'effectue entre les PNJ
  11. And un indicateur du témoignage actuel est visible (ex: 2/5)
  12. And la navigation au clavier est fonctionnelle (flèches gauche/droite)

Tasks / Subtasks

  • Task 1: Créer le composant DialoguePNJ (AC: #1, #2, #5, #6)

    • Créer frontend/app/components/feature/DialoguePNJ.vue
    • Props : testimonials (array), initialIndex (number)
    • Layout : avatar à gauche, bulle de dialogue à droite
    • Styles différents selon personality
  • Task 2: Implémenter l'effet typewriter (AC: #3, #4)

    • Créer un composable useTypewriter pour l'animation
    • Afficher le texte lettre par lettre (vitesse ~30-50ms)
    • Clic ou Espace accélère l'animation (x3-x5)
    • État : "typing" ou "complete"
  • Task 3: Gérer prefers-reduced-motion (AC: #7)

    • Détecter la préférence via media query
    • Si activé, afficher le texte complet instantanément
    • Créer un composable useReducedMotion()
  • Task 4: Accessibilité (AC: #8)

    • Ajouter aria-label avec le texte complet
    • role="article" sur le conteneur de dialogue
    • aria-live="polite" pour annoncer les changements
  • Task 5: Navigation entre témoignages (AC: #9, #10, #11, #12)

    • Boutons précédent/suivant
    • Indicateur de position (2/5)
    • Transition animée entre les PNJ (fade/slide)
    • Navigation clavier : flèches gauche/droite
    • Focus trap sur le composant
  • Task 6: Intégrer dans la page Témoignages (AC: tous)

    • Remplacer les TestimonialCards par DialoguePNJ
    • Mode "dialogue" pour l'expérience immersive
    • Option pour revenir à la vue "liste"
  • Task 7: Styles visuels par personnalité (AC: #5)

    • sage : bulle bleutée, bordure calme
    • sarcastique : bulle violacée, italique
    • enthousiaste : bulle orange accent, texte dynamique
    • professionnel : bulle grise, sobre
  • Task 8: Tests et validation

    • Tester l'effet typewriter
    • Valider l'accélération au clic/Espace
    • Tester prefers-reduced-motion
    • Valider la navigation clavier
    • Vérifier l'accessibilité avec screen reader

Dev Notes

Composable useTypewriter

// frontend/app/composables/useTypewriter.ts
export interface UseTypewriterOptions {
  text: string
  speed?: number          // ms entre chaque caractère
  speedMultiplier?: number // facteur d'accélération
}

export function useTypewriter(options: UseTypewriterOptions) {
  const { text, speed = 40, speedMultiplier = 5 } = options

  const displayedText = ref('')
  const isTyping = ref(true)
  const isAccelerated = ref(false)
  let timeoutId: NodeJS.Timeout | null = null
  let currentIndex = 0

  const reducedMotion = useReducedMotion()

  function typeNextChar() {
    if (currentIndex < text.length) {
      displayedText.value += text[currentIndex]
      currentIndex++

      const currentSpeed = isAccelerated.value ? speed / speedMultiplier : speed
      timeoutId = setTimeout(typeNextChar, currentSpeed)
    } else {
      isTyping.value = false
    }
  }

  function start() {
    if (reducedMotion.value) {
      // Afficher tout le texte immédiatement
      displayedText.value = text
      isTyping.value = false
      return
    }

    displayedText.value = ''
    currentIndex = 0
    isTyping.value = true
    isAccelerated.value = false
    typeNextChar()
  }

  function accelerate() {
    isAccelerated.value = true
  }

  function skip() {
    if (timeoutId) clearTimeout(timeoutId)
    displayedText.value = text
    isTyping.value = false
  }

  function reset() {
    if (timeoutId) clearTimeout(timeoutId)
    displayedText.value = ''
    currentIndex = 0
    isTyping.value = true
    isAccelerated.value = false
  }

  onMounted(() => {
    start()
  })

  onUnmounted(() => {
    if (timeoutId) clearTimeout(timeoutId)
  })

  return {
    displayedText: readonly(displayedText),
    isTyping: readonly(isTyping),
    accelerate,
    skip,
    reset,
    start,
  }
}

Composable useReducedMotion

// frontend/app/composables/useReducedMotion.ts
export function useReducedMotion() {
  const reducedMotion = ref(false)

  onMounted(() => {
    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
    reducedMotion.value = mediaQuery.matches

    const handler = (e: MediaQueryListEvent) => {
      reducedMotion.value = e.matches
    }

    mediaQuery.addEventListener('change', handler)

    onUnmounted(() => {
      mediaQuery.removeEventListener('change', handler)
    })
  })

  return readonly(reducedMotion)
}

Composant DialoguePNJ

<!-- frontend/app/components/feature/DialoguePNJ.vue -->
<script setup lang="ts">
import type { Testimonial } from '~/types/testimonial'

const props = withDefaults(defineProps<{
  testimonials: Testimonial[]
  initialIndex?: number
}>(), {
  initialIndex: 0,
})

const emit = defineEmits<{
  complete: []
}>()

const { t } = useI18n()
const localePath = useLocalePath()
const reducedMotion = useReducedMotion()

// État du dialogue actuel
const currentIndex = ref(props.initialIndex)
const currentTestimonial = computed(() => props.testimonials[currentIndex.value])
const totalCount = computed(() => props.testimonials.length)

// Typewriter
const typewriterKey = ref(0) // Pour forcer le reset
const { displayedText, isTyping, accelerate, skip, start } = useTypewriter({
  text: computed(() => currentTestimonial.value?.text ?? ''),
})

// Watch pour restart le typewriter quand le témoignage change
watch(currentIndex, () => {
  typewriterKey.value++
  nextTick(() => start())
})

// Navigation
function goToPrevious() {
  if (currentIndex.value > 0) {
    currentIndex.value--
  }
}

function goToNext() {
  if (currentIndex.value < totalCount.value - 1) {
    currentIndex.value++
  } else {
    emit('complete')
  }
}

// Interaction clavier
function handleKeydown(e: KeyboardEvent) {
  switch (e.key) {
    case 'ArrowLeft':
      goToPrevious()
      break
    case 'ArrowRight':
      if (!isTyping.value) goToNext()
      break
    case ' ':
    case 'Enter':
      if (isTyping.value) {
        accelerate()
      } else {
        goToNext()
      }
      break
  }
}

// Interaction clic
function handleClick() {
  if (isTyping.value) {
    accelerate()
  }
}

// Styles selon personnalité
const personalityStyles = {
  sage: {
    bubble: 'bg-blue-400/10 border-l-4 border-blue-400',
    text: 'text-sky-text',
  },
  sarcastique: {
    bubble: 'bg-purple-400/10 border-l-4 border-purple-400',
    text: 'text-sky-text italic',
  },
  enthousiaste: {
    bubble: 'bg-sky-accent/10 border-l-4 border-sky-accent',
    text: 'text-sky-text',
  },
  professionnel: {
    bubble: 'bg-gray-400/10 border-l-4 border-gray-400',
    text: 'text-sky-text',
  },
}

const currentStyle = computed(() =>
  personalityStyles[currentTestimonial.value?.personality ?? 'professionnel']
)
</script>

<template>
  <div
    class="dialogue-pnj"
    tabindex="0"
    role="article"
    :aria-label="currentTestimonial?.text"
    @keydown="handleKeydown"
    @click="handleClick"
  >
    <Transition name="fade" mode="out-in">
      <div :key="currentIndex" class="flex items-start gap-6">
        <!-- Avatar PNJ -->
        <div class="flex-shrink-0">
          <div class="w-24 h-24 md:w-32 md:h-32 rounded-full overflow-hidden bg-sky-dark-50 border-4 border-sky-dark-100 shadow-lg">
            <NuxtImg
              v-if="currentTestimonial?.avatar"
              :src="currentTestimonial.avatar"
              :alt="currentTestimonial.name"
              format="webp"
              width="128"
              height="128"
              class="w-full h-full object-cover"
            />
            <div v-else class="w-full h-full flex items-center justify-center text-4xl text-sky-text-muted">
              👤
            </div>
          </div>

          <!-- Info PNJ sous l'avatar -->
          <div class="mt-3 text-center">
            <p class="font-ui font-semibold text-sky-text text-sm">
              {{ currentTestimonial?.name }}
            </p>
            <p class="font-ui text-xs text-sky-text-muted">
              {{ currentTestimonial?.role }}
            </p>
            <p v-if="currentTestimonial?.company" class="font-ui text-xs text-sky-text-muted">
              @ {{ currentTestimonial.company }}
            </p>
          </div>
        </div>

        <!-- Bulle de dialogue -->
        <div class="flex-1">
          <div
            class="relative p-6 rounded-lg"
            :class="currentStyle.bubble"
            aria-live="polite"
          >
            <!-- Triangle de la bulle -->
            <div
              class="absolute left-0 top-8 w-0 h-0 -translate-x-full"
              :class="{
                'border-t-8 border-r-8 border-b-8 border-transparent border-r-blue-400/10': currentTestimonial?.personality === 'sage',
                'border-t-8 border-r-8 border-b-8 border-transparent border-r-purple-400/10': currentTestimonial?.personality === 'sarcastique',
                'border-t-8 border-r-8 border-b-8 border-transparent border-r-sky-accent/10': currentTestimonial?.personality === 'enthousiaste',
                'border-t-8 border-r-8 border-b-8 border-transparent border-r-gray-400/10': currentTestimonial?.personality === 'professionnel',
              }"
            ></div>

            <!-- Texte avec typewriter -->
            <p
              :key="typewriterKey"
              class="font-narrative text-lg leading-relaxed min-h-[4rem]"
              :class="currentStyle.text"
            >
              "{{ displayedText }}"
              <span v-if="isTyping" class="animate-blink">|</span>
            </p>

            <!-- Indicateur pour continuer -->
            <div
              v-if="!isTyping"
              class="mt-4 text-sm text-sky-text-muted animate-pulse"
            >
              {{ t('testimonials.clickToContinue') }}
            </div>
          </div>

          <!-- Lien projet si existant -->
          <NuxtLink
            v-if="currentTestimonial?.project"
            :to="localePath(`/projets/${currentTestimonial.project.slug}`)"
            class="inline-flex items-center mt-3 text-sm text-sky-accent hover:underline"
          >
            📁 {{ currentTestimonial.project.title }}
          </NuxtLink>
        </div>
      </div>
    </Transition>

    <!-- Navigation et indicateur -->
    <div class="flex items-center justify-between mt-8">
      <!-- Bouton précédent -->
      <button
        type="button"
        :disabled="currentIndex === 0"
        class="px-4 py-2 text-sky-text-muted hover:text-sky-text disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
        @click.stop="goToPrevious"
      >
        ← {{ t('testimonials.previous') }}
      </button>

      <!-- Indicateur position -->
      <div class="flex items-center gap-2">
        <span
          v-for="(_, idx) in testimonials"
          :key="idx"
          class="w-2 h-2 rounded-full transition-colors"
          :class="idx === currentIndex ? 'bg-sky-accent' : 'bg-sky-dark-100'"
        ></span>
      </div>

      <!-- Bouton suivant -->
      <button
        type="button"
        :disabled="isTyping"
        class="px-4 py-2 text-sky-text-muted hover:text-sky-text disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
        @click.stop="goToNext"
      >
        {{ currentIndex === totalCount - 1 ? t('testimonials.finish') : t('testimonials.next') }} →
      </button>
    </div>

    <!-- Instructions clavier -->
    <p class="mt-4 text-xs text-sky-text-muted text-center">
      {{ t('testimonials.keyboardHint') }}
    </p>
  </div>
</template>

<style scoped>
.dialogue-pnj:focus {
  outline: none;
}

.dialogue-pnj:focus-visible {
  outline: 2px solid theme('colors.sky-accent.DEFAULT');
  outline-offset: 4px;
  border-radius: 0.5rem;
}

.animate-blink {
  animation: blink 0.7s infinite;
}

@keyframes blink {
  0%, 50% { opacity: 1; }
  51%, 100% { opacity: 0; }
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.fade-enter-from {
  opacity: 0;
  transform: translateX(20px);
}

.fade-leave-to {
  opacity: 0;
  transform: translateX(-20px);
}

@media (prefers-reduced-motion: reduce) {
  .animate-blink {
    animation: none;
    opacity: 1;
  }

  .fade-enter-active,
  .fade-leave-active {
    transition: none;
  }
}
</style>

Modification de la page Témoignages

<!-- frontend/app/pages/temoignages.vue - Version avec DialoguePNJ -->
<script setup lang="ts">
const { t } = useI18n()
const { data, pending, error, refresh } = useFetchTestimonials()

const testimonials = computed(() => data.value?.data ?? [])

// Mode d'affichage
const viewMode = ref<'dialogue' | 'list'>('dialogue')

// SEO
useHead({
  title: () => t('testimonials.pageTitle'),
})

useSeoMeta({
  title: () => t('testimonials.pageTitle'),
  description: () => t('testimonials.pageDescription'),
  ogTitle: () => t('testimonials.pageTitle'),
  ogDescription: () => t('testimonials.pageDescription'),
})

function handleDialogueComplete() {
  // Optionnel : action à la fin du dialogue
}
</script>

<template>
  <div class="container mx-auto px-4 py-8">
    <div class="flex items-center justify-between mb-8">
      <h1 class="text-3xl font-ui font-bold text-sky-text">
        {{ t('testimonials.title') }}
      </h1>

      <!-- Toggle vue -->
      <div class="flex gap-2">
        <button
          type="button"
          :class="viewMode === 'dialogue' ? 'bg-sky-accent text-white' : 'bg-sky-dark-50 text-sky-text-muted'"
          class="px-4 py-2 rounded-lg text-sm transition-colors"
          @click="viewMode = 'dialogue'"
        >
          💬 {{ t('testimonials.dialogueMode') }}
        </button>
        <button
          type="button"
          :class="viewMode === 'list' ? 'bg-sky-accent text-white' : 'bg-sky-dark-50 text-sky-text-muted'"
          class="px-4 py-2 rounded-lg text-sm transition-colors"
          @click="viewMode = 'list'"
        >
          📋 {{ t('testimonials.listMode') }}
        </button>
      </div>
    </div>

    <!-- Loading -->
    <div v-if="pending" class="flex items-center justify-center py-16">
      <div class="animate-spin w-8 h-8 border-4 border-sky-accent border-t-transparent rounded-full"></div>
    </div>

    <!-- Error -->
    <div v-else-if="error" class="text-center py-12">
      <p class="text-sky-text-muted mb-4">{{ t('testimonials.loadError') }}</p>
      <button
        @click="refresh()"
        class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
      >
        {{ t('common.retry') }}
      </button>
    </div>

    <!-- Content -->
    <template v-else>
      <!-- Mode Dialogue -->
      <DialoguePNJ
        v-if="viewMode === 'dialogue'"
        :testimonials="testimonials"
        @complete="handleDialogueComplete"
      />

      <!-- Mode Liste -->
      <div v-else class="space-y-6">
        <TestimonialCard
          v-for="testimonial in testimonials"
          :key="testimonial.id"
          :testimonial="testimonial"
        />
      </div>
    </template>
  </div>
</template>

Clés i18n nécessaires

fr.json :

{
  "testimonials": {
    "clickToContinue": "Cliquez ou appuyez sur Espace pour continuer...",
    "previous": "Précédent",
    "next": "Suivant",
    "finish": "Terminer",
    "keyboardHint": "Utilisez les flèches ← → pour naviguer, Espace pour accélérer",
    "dialogueMode": "Dialogue",
    "listMode": "Liste"
  }
}

en.json :

{
  "testimonials": {
    "clickToContinue": "Click or press Space to continue...",
    "previous": "Previous",
    "next": "Next",
    "finish": "Finish",
    "keyboardHint": "Use ← → arrows to navigate, Space to speed up",
    "dialogueMode": "Dialogue",
    "listMode": "List"
  }
}

Dépendances

Cette story nécessite :

  • Story 2.6 : Table testimonials, API, type Testimonial

Cette story prépare pour :

  • Story 3.2 : NarratorBubble (pattern similaire typewriter)

Project Structure Notes

Fichiers à créer :

frontend/app/
├── components/feature/
│   └── DialoguePNJ.vue          # CRÉER
└── composables/
    ├── useTypewriter.ts         # CRÉER
    └── useReducedMotion.ts      # CRÉER

Fichiers à modifier :

frontend/app/pages/temoignages.vue  # MODIFIER pour intégrer DialoguePNJ
frontend/i18n/fr.json               # AJOUTER clés
frontend/i18n/en.json               # AJOUTER clés

References

  • [Source: docs/planning-artifacts/epics.md#Story-2.7]
  • [Source: docs/planning-artifacts/ux-design-specification.md#DialoguePNJ]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Accessibility-Strategy]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Typography-System]

Technical Requirements

Requirement Value Source
Typewriter speed 30-50ms par caractère UX Spec
Accélération x3-x5 Epics
Police font-narrative (serif) UX Spec
prefers-reduced-motion Texte instantané NFR6
Accessibilité aria-label, keyboard nav WCAG AA

Dev Agent Record

Agent Model Used

{{agent_model_name_version}}

Debug Log References

Completion Notes List

Change Log

Date Change Author
2026-02-04 Story créée avec contexte complet SM Agent

File List