# Story 2.7: Composant Dialogue PNJ

Status: review

## 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

- [x] **Task 1: Créer le composant DialoguePNJ** (AC: #1, #2, #5, #6)
  - [x] Créer `frontend/app/components/feature/DialoguePNJ.vue`
  - [x] Props : testimonials (array), initialIndex (number)
  - [x] Layout : avatar à gauche, bulle de dialogue à droite
  - [x] Styles différents selon personality

- [x] **Task 2: Implémenter l'effet typewriter** (AC: #3, #4)
  - [x] Créer un composable `useTypewriter` pour l'animation
  - [x] Afficher le texte lettre par lettre (vitesse ~35ms)
  - [x] Clic ou Espace accélère l'animation (x5)
  - [x] État : "typing" ou "complete"

- [x] **Task 3: Gérer prefers-reduced-motion** (AC: #7)
  - [x] Détecter la préférence via media query
  - [x] Si activé, afficher le texte complet instantanément
  - [x] Créer un composable `useReducedMotion()`

- [x] **Task 4: Accessibilité** (AC: #8)
  - [x] Ajouter `aria-label` avec le texte complet
  - [x] `role="article"` sur le conteneur de dialogue
  - [x] `aria-live="polite"` pour annoncer les changements

- [x] **Task 5: Navigation entre témoignages** (AC: #9, #10, #11, #12)
  - [x] Boutons précédent/suivant
  - [x] Indicateur de position (dots cliquables)
  - [x] Transition animée entre les PNJ (fade/slide)
  - [x] Navigation clavier : flèches gauche/droite
  - [x] Focus sur le composant

- [x] **Task 6: Intégrer dans la page Témoignages** (AC: tous)
  - [x] Ajouter DialoguePNJ avec toggle
  - [x] Mode "dialogue" pour l'expérience immersive
  - [x] Option pour revenir à la vue "liste"

- [x] **Task 7: Styles visuels par personnalité** (AC: #5)
  - [x] sage : bulle emerald, bordure calme
  - [x] sarcastique : bulle purple, italique
  - [x] enthousiaste : bulle amber, texte dynamique
  - [x] professionnel : bulle sky, sobre

- [x] **Task 8: Tests et validation**
  - [x] Build validé
  - [x] Effet typewriter fonctionnel
  - [x] Accélération au clic/Espace
  - [x] prefers-reduced-motion respecté
  - [x] Navigation clavier fonctionnelle

## Dev Notes

### Composable useTypewriter

```typescript
// 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

```typescript
// 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

```vue
<!-- 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

```vue
<!-- 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 :**
```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 :**
```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 |
| 2026-02-06 | Implémentation complète: composables typewriter, DialoguePNJ, toggle mode | Claude Opus 4.5 |

### File List

