Files
Portfolio-Game/docs/implementation-artifacts/2-7-composant-dialogue-pnj.md
skycel cfc9cca34f Add DialoguePNJ component with typewriter effect (Story 2.7)
- Create useReducedMotion composable for motion preferences
- Create useTypewriter composable with accelerate/skip support
- Add DialoguePNJ component with Zelda-style dialogue system
- Add personality-based styling (sage, sarcastique, enthousiaste, professionnel)
- Implement keyboard navigation (arrows, space)
- Add toggle between dialogue and list view modes
- Add i18n translations for dialogue UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 11:07:40 +01:00

664 lines
18 KiB
Markdown

# 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