- 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>
18 KiB
18 KiB
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
- Given le composant
DialoguePNJest 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 - And une bulle de dialogue s'affiche à droite avec le texte
- And l'effet typewriter fait apparaître le texte lettre par lettre
- And un clic ou appui sur Espace accélère l'animation typewriter (x3-x5)
- And la personnalité du PNJ influence le style visuel de la bulle (sage, sarcastique, enthousiaste, professionnel)
- And la police serif narrative est utilisée pour le texte du dialogue
- And
prefers-reduced-motionaffiche le texte complet instantanément - And le texte complet est accessible via
aria-labelpour les screen readers - And une navigation entre témoignages est disponible (précédent/suivant)
- And une transition animée s'effectue entre les PNJ
- And un indicateur du témoignage actuel est visible (ex: 2/5)
- 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
- Créer
-
Task 2: Implémenter l'effet typewriter (AC: #3, #4)
- Créer un composable
useTypewriterpour l'animation - Afficher le texte lettre par lettre (vitesse ~35ms)
- Clic ou Espace accélère l'animation (x5)
- État : "typing" ou "complete"
- Créer un composable
-
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-labelavec le texte complet role="article"sur le conteneur de dialoguearia-live="polite"pour annoncer les changements
- Ajouter
-
Task 5: Navigation entre témoignages (AC: #9, #10, #11, #12)
- Boutons précédent/suivant
- Indicateur de position (dots cliquables)
- Transition animée entre les PNJ (fade/slide)
- Navigation clavier : flèches gauche/droite
- Focus sur le composant
-
Task 6: Intégrer dans la page Témoignages (AC: tous)
- Ajouter DialoguePNJ avec toggle
- Mode "dialogue" pour l'expérience immersive
- Option pour revenir à la vue "liste"
-
Task 7: Styles visuels par personnalité (AC: #5)
- sage : bulle emerald, bordure calme
- sarcastique : bulle purple, italique
- enthousiaste : bulle amber, texte dynamique
- professionnel : bulle sky, sobre
-
Task 8: Tests et validation
- Build validé
- Effet typewriter fonctionnel
- Accélération au clic/Espace
- prefers-reduced-motion respecté
- Navigation clavier fonctionnelle
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 |
| 2026-02-06 | Implémentation complète: composables typewriter, DialoguePNJ, toggle mode | Claude Opus 4.5 |