Story 3.2 : Implémentation du narrateur-guide "Le Bug" - Composant NarratorBubble.vue avec effet typewriter - 5 SVG représentant l'évolution de la mascotte (silhouette à révélation) - Animation slide-up/fade-out avec prefers-reduced-motion - Support clavier (Espace/Entrée pour skip, Échap pour fermer) - Accessibilité (aria-live, role="status", sr-only) - Responsive (position adaptée mobile avec bottom-bar) - Traductions narrator.clickToSkip et narrator.bugAlt Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
495 lines
13 KiB
Markdown
495 lines
13 KiB
Markdown
# Story 3.2: Composant NarratorBubble (Le Bug)
|
|
|
|
Status: review
|
|
|
|
## Story
|
|
|
|
As a visiteur,
|
|
I want voir un narrateur-guide qui m'accompagne dans mon exploration,
|
|
so that je me sens guidé et l'expérience est immersive.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Given** le composant `NarratorBubble` est implémenté **When** le narrateur doit afficher un message **Then** une bulle apparaît en bas de l'écran (desktop) ou au-dessus de la bottom bar (mobile)
|
|
2. **And** l'avatar du Bug (araignée) s'affiche avec son apparence selon le `narratorStage` du store
|
|
3. **And** le texte apparaît avec effet typewriter (lettre par lettre)
|
|
4. **And** un clic ou Espace accélère l'animation typewriter
|
|
5. **And** la bulle peut être fermée/minimisée sans bloquer la navigation
|
|
6. **And** le composant utilise `aria-live="polite"` et `role="status"` pour l'accessibilité
|
|
7. **And** `prefers-reduced-motion` affiche le texte instantanément
|
|
8. **And** la police serif narrative est utilisée pour le texte
|
|
9. **And** l'animation d'apparition/disparition est fluide et non-bloquante
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [x] **Task 1: Créer le composable useTypewriter** (AC: #3, #4, #7)
|
|
- [x] Créer `frontend/app/composables/useTypewriter.ts` (existait déjà de Story 2.7)
|
|
- [x] Accepter le texte en paramètre
|
|
- [x] Afficher lettre par lettre (30-50ms par lettre)
|
|
- [x] Exposer une méthode `skip()` pour afficher tout le texte instantanément
|
|
- [x] Respecter `prefers-reduced-motion`
|
|
|
|
- [x] **Task 2: Créer les assets du Bug par stage** (AC: #2)
|
|
- [x] Préparer 5 images SVG ou PNG pour les 5 stades du Bug
|
|
- [x] Stage 1 : silhouette sombre floue
|
|
- [x] Stage 2 : forme vague avec yeux
|
|
- [x] Stage 3 : pattes visibles
|
|
- [x] Stage 4 : araignée reconnaissable
|
|
- [x] Stage 5 : mascotte complète révélée
|
|
- [x] Placer dans `frontend/public/images/bug/`
|
|
|
|
- [x] **Task 3: Créer le composant NarratorBubble** (AC: #1, #2, #3, #4, #5, #8, #9)
|
|
- [x] Créer `frontend/app/components/feature/NarratorBubble.vue`
|
|
- [x] Props : message (string), visible (boolean)
|
|
- [x] Emit : close, skip
|
|
- [x] Afficher l'avatar du Bug selon `narratorStage` du store
|
|
- [x] Intégrer le composable useTypewriter
|
|
- [x] Bouton de fermeture/minimisation
|
|
- [x] Utiliser font-narrative pour le texte
|
|
|
|
- [x] **Task 4: Implémenter l'accessibilité** (AC: #6, #7)
|
|
- [x] Ajouter `aria-live="polite"` sur le conteneur
|
|
- [x] Ajouter `role="status"` pour signaler les mises à jour
|
|
- [x] S'assurer que le texte complet est accessible même pendant l'animation
|
|
- [x] Tester avec prefers-reduced-motion
|
|
|
|
- [x] **Task 5: Animation d'apparition/disparition** (AC: #9)
|
|
- [x] Slide-up pour l'apparition
|
|
- [x] Fade-out pour la disparition
|
|
- [x] Utiliser CSS transitions pour fluidité
|
|
- [x] Non-bloquante : ne pas empêcher les interactions avec le reste de la page
|
|
|
|
- [x] **Task 6: Responsive design** (AC: #1)
|
|
- [x] Desktop : bulle en bas de l'écran (position fixed)
|
|
- [x] Mobile : au-dessus de la bottom bar (variable CSS pour le spacing)
|
|
- [x] Taille adaptée à l'écran
|
|
|
|
- [x] **Task 7: Tests et validation**
|
|
- [x] Tester l'effet typewriter
|
|
- [x] Tester le skip au clic/Espace
|
|
- [x] Vérifier les 5 stades du Bug
|
|
- [x] Valider l'accessibilité (screen reader)
|
|
- [x] Tester prefers-reduced-motion
|
|
- [x] Valider responsive (desktop/mobile)
|
|
|
|
## Dev Notes
|
|
|
|
### Composable useTypewriter
|
|
|
|
```typescript
|
|
// frontend/app/composables/useTypewriter.ts
|
|
export interface UseTypewriterOptions {
|
|
speed?: number // ms par caractère
|
|
onComplete?: () => void
|
|
}
|
|
|
|
export function useTypewriter(options: UseTypewriterOptions = {}) {
|
|
const { speed = 40, onComplete } = options
|
|
|
|
const text = ref('')
|
|
const displayedText = ref('')
|
|
const isTyping = ref(false)
|
|
const isComplete = ref(false)
|
|
|
|
const reducedMotion = useReducedMotion()
|
|
|
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
let currentIndex = 0
|
|
|
|
function start(newText: string) {
|
|
text.value = newText
|
|
displayedText.value = ''
|
|
currentIndex = 0
|
|
isTyping.value = true
|
|
isComplete.value = false
|
|
|
|
// Si prefers-reduced-motion, afficher tout instantanément
|
|
if (reducedMotion.value) {
|
|
skip()
|
|
return
|
|
}
|
|
|
|
intervalId = setInterval(() => {
|
|
if (currentIndex < text.value.length) {
|
|
displayedText.value += text.value[currentIndex]
|
|
currentIndex++
|
|
} else {
|
|
complete()
|
|
}
|
|
}, speed)
|
|
}
|
|
|
|
function skip() {
|
|
if (intervalId) {
|
|
clearInterval(intervalId)
|
|
intervalId = null
|
|
}
|
|
displayedText.value = text.value
|
|
complete()
|
|
}
|
|
|
|
function complete() {
|
|
if (intervalId) {
|
|
clearInterval(intervalId)
|
|
intervalId = null
|
|
}
|
|
isTyping.value = false
|
|
isComplete.value = true
|
|
onComplete?.()
|
|
}
|
|
|
|
function reset() {
|
|
if (intervalId) {
|
|
clearInterval(intervalId)
|
|
intervalId = null
|
|
}
|
|
text.value = ''
|
|
displayedText.value = ''
|
|
currentIndex = 0
|
|
isTyping.value = false
|
|
isComplete.value = false
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (intervalId) {
|
|
clearInterval(intervalId)
|
|
}
|
|
})
|
|
|
|
return {
|
|
text,
|
|
displayedText,
|
|
isTyping,
|
|
isComplete,
|
|
start,
|
|
skip,
|
|
reset,
|
|
}
|
|
}
|
|
```
|
|
|
|
### 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 reducedMotion
|
|
}
|
|
```
|
|
|
|
### Composant NarratorBubble
|
|
|
|
```vue
|
|
<!-- frontend/app/components/feature/NarratorBubble.vue -->
|
|
<script setup lang="ts">
|
|
const props = defineProps<{
|
|
message: string
|
|
visible: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
skip: []
|
|
}>()
|
|
|
|
const progressionStore = useProgressionStore()
|
|
const { displayedText, isTyping, isComplete, start, skip } = useTypewriter({
|
|
speed: 40,
|
|
})
|
|
|
|
// Images du Bug par stage
|
|
const bugImages: Record<number, string> = {
|
|
1: '/images/bug/bug-stage-1.svg',
|
|
2: '/images/bug/bug-stage-2.svg',
|
|
3: '/images/bug/bug-stage-3.svg',
|
|
4: '/images/bug/bug-stage-4.svg',
|
|
5: '/images/bug/bug-stage-5.svg',
|
|
}
|
|
|
|
const currentBugImage = computed(() => {
|
|
return bugImages[progressionStore.narratorStage] || bugImages[1]
|
|
})
|
|
|
|
// Démarrer l'animation quand le message change
|
|
watch(() => props.message, (newMessage) => {
|
|
if (newMessage && props.visible) {
|
|
start(newMessage)
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Écouter les clics et touches pour skip
|
|
function handleInteraction() {
|
|
if (isTyping.value) {
|
|
skip()
|
|
emit('skip')
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.code === 'Space' || e.code === 'Enter') {
|
|
e.preventDefault()
|
|
handleInteraction()
|
|
}
|
|
if (e.code === 'Escape') {
|
|
emit('close')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Transition name="narrator-slide">
|
|
<div
|
|
v-if="visible"
|
|
class="narrator-bubble fixed bottom-4 left-4 right-4 md:left-auto md:right-8 md:max-w-md z-50"
|
|
role="status"
|
|
aria-live="polite"
|
|
@click="handleInteraction"
|
|
@keydown="handleKeydown"
|
|
tabindex="0"
|
|
>
|
|
<div class="flex items-start gap-4 bg-sky-dark-50 rounded-xl p-4 shadow-xl border border-sky-dark-100">
|
|
<!-- Avatar du Bug -->
|
|
<div class="shrink-0 w-16 h-16 md:w-20 md:h-20">
|
|
<img
|
|
:src="currentBugImage"
|
|
:alt="`Le Bug - Stade ${progressionStore.narratorStage}`"
|
|
class="w-full h-full object-contain"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Contenu -->
|
|
<div class="flex-1 min-w-0">
|
|
<!-- Texte avec typewriter -->
|
|
<p class="font-narrative text-sky-text text-base md:text-lg leading-relaxed">
|
|
{{ displayedText }}
|
|
<span
|
|
v-if="isTyping"
|
|
class="inline-block w-0.5 h-5 bg-sky-accent animate-blink ml-0.5"
|
|
></span>
|
|
</p>
|
|
|
|
<!-- Texte complet pour screen readers (caché visuellement) -->
|
|
<span class="sr-only">{{ message }}</span>
|
|
|
|
<!-- Indicateur de skip -->
|
|
<p
|
|
v-if="isTyping"
|
|
class="text-xs text-sky-text-muted mt-2 font-ui"
|
|
>
|
|
{{ $t('narrator.clickToSkip') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Bouton fermer -->
|
|
<button
|
|
type="button"
|
|
class="shrink-0 p-1 text-sky-text-muted hover:text-sky-text transition-colors"
|
|
:aria-label="$t('common.close')"
|
|
@click.stop="emit('close')"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.narrator-slide-enter-active,
|
|
.narrator-slide-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.narrator-slide-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
}
|
|
|
|
.narrator-slide-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
|
|
@keyframes blink {
|
|
0%, 50% { opacity: 1; }
|
|
51%, 100% { opacity: 0; }
|
|
}
|
|
|
|
.animate-blink {
|
|
animation: blink 1s infinite;
|
|
}
|
|
|
|
/* Position mobile : au-dessus de la bottom bar */
|
|
@media (max-width: 767px) {
|
|
.narrator-bubble {
|
|
bottom: calc(var(--bottom-bar-height, 64px) + 1rem);
|
|
}
|
|
}
|
|
|
|
/* Prefers reduced motion */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.narrator-slide-enter-active,
|
|
.narrator-slide-leave-active {
|
|
transition: opacity 0.15s ease;
|
|
transform: none;
|
|
}
|
|
|
|
.animate-blink {
|
|
animation: none;
|
|
opacity: 1;
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### Clés i18n à ajouter
|
|
|
|
**fr.json :**
|
|
```json
|
|
{
|
|
"narrator": {
|
|
"clickToSkip": "Cliquez ou appuyez sur Espace pour passer"
|
|
}
|
|
}
|
|
```
|
|
|
|
**en.json :**
|
|
```json
|
|
{
|
|
"narrator": {
|
|
"clickToSkip": "Click or press Space to skip"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Structure des assets du Bug
|
|
|
|
```
|
|
frontend/public/images/bug/
|
|
├── bug-stage-1.svg # Silhouette sombre floue
|
|
├── bug-stage-2.svg # Forme vague avec yeux
|
|
├── bug-stage-3.svg # Pattes visibles
|
|
├── bug-stage-4.svg # Araignée reconnaissable
|
|
└── bug-stage-5.svg # Mascotte complète révélée
|
|
```
|
|
|
|
### Utilisation du composant
|
|
|
|
```vue
|
|
<!-- Exemple d'utilisation dans un layout ou page -->
|
|
<script setup>
|
|
const showNarrator = ref(true)
|
|
const narratorMessage = ref('')
|
|
|
|
const { fetchText } = useFetchNarratorText()
|
|
const progressionStore = useProgressionStore()
|
|
|
|
async function showIntro() {
|
|
const response = await fetchText('intro', progressionStore.heroType)
|
|
narratorMessage.value = response.data.text
|
|
showNarrator.value = true
|
|
}
|
|
|
|
function handleClose() {
|
|
showNarrator.value = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<NarratorBubble
|
|
:message="narratorMessage"
|
|
:visible="showNarrator"
|
|
@close="handleClose"
|
|
/>
|
|
</template>
|
|
```
|
|
|
|
### Dépendances
|
|
|
|
**Cette story nécessite :**
|
|
- Story 3.1 : API narrateur pour les textes
|
|
- Story 1.6 : Store Pinia (pour narratorStage)
|
|
|
|
**Cette story prépare pour :**
|
|
- Story 3.3 : Textes contextuels (utilise ce composant)
|
|
- Story 3.5 : Logique de progression (déclenche le narrateur)
|
|
|
|
### Project Structure Notes
|
|
|
|
**Fichiers à créer :**
|
|
```
|
|
frontend/app/
|
|
├── components/feature/
|
|
│ └── NarratorBubble.vue # CRÉER
|
|
├── composables/
|
|
│ ├── useTypewriter.ts # CRÉER
|
|
│ └── useReducedMotion.ts # CRÉER
|
|
└── public/images/bug/
|
|
├── bug-stage-1.svg # CRÉER (asset)
|
|
├── bug-stage-2.svg # CRÉER (asset)
|
|
├── bug-stage-3.svg # CRÉER (asset)
|
|
├── bug-stage-4.svg # CRÉER (asset)
|
|
└── bug-stage-5.svg # CRÉER (asset)
|
|
```
|
|
|
|
**Fichiers à modifier :**
|
|
```
|
|
frontend/i18n/fr.json # AJOUTER narrator.clickToSkip
|
|
frontend/i18n/en.json # AJOUTER narrator.clickToSkip
|
|
```
|
|
|
|
### References
|
|
|
|
- [Source: docs/planning-artifacts/epics.md#Story-3.2]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#NarratorBubble]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Revelation-Arc]
|
|
- [Source: docs/brainstorming-gamification-2026-01-26.md#Mascotte-Le-Bug]
|
|
|
|
### Technical Requirements
|
|
|
|
| Requirement | Value | Source |
|
|
|-------------|-------|--------|
|
|
| Effect typewriter | 30-50ms par lettre | Epics |
|
|
| Stades du Bug | 5 apparences distinctes | UX Spec |
|
|
| Position desktop | Bottom fixed | Epics |
|
|
| Position mobile | Au-dessus bottom bar | Epics |
|
|
| Accessibilité | aria-live + role="status" | Epics |
|
|
| Police | font-narrative | UX Spec |
|
|
|
|
## 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
|
|
|