- 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>
664 lines
18 KiB
Markdown
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
|
|
|