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>
13 KiB
13 KiB
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
- Given le composant
NarratorBubbleest 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) - And l'avatar du Bug (araignée) s'affiche avec son apparence selon le
narratorStagedu store - And le texte apparaît avec effet typewriter (lettre par lettre)
- And un clic ou Espace accélère l'animation typewriter
- And la bulle peut être fermée/minimisée sans bloquer la navigation
- And le composant utilise
aria-live="polite"etrole="status"pour l'accessibilité - And
prefers-reduced-motionaffiche le texte instantanément - And la police serif narrative est utilisée pour le texte
- And l'animation d'apparition/disparition est fluide et non-bloquante
Tasks / Subtasks
-
Task 1: Créer le composable useTypewriter (AC: #3, #4, #7)
- Créer
frontend/app/composables/useTypewriter.ts(existait déjà de Story 2.7) - Accepter le texte en paramètre
- Afficher lettre par lettre (30-50ms par lettre)
- Exposer une méthode
skip()pour afficher tout le texte instantanément - Respecter
prefers-reduced-motion
- Créer
-
Task 2: Créer les assets du Bug par stage (AC: #2)
- Préparer 5 images SVG ou PNG pour les 5 stades du Bug
- Stage 1 : silhouette sombre floue
- Stage 2 : forme vague avec yeux
- Stage 3 : pattes visibles
- Stage 4 : araignée reconnaissable
- Stage 5 : mascotte complète révélée
- Placer dans
frontend/public/images/bug/
-
Task 3: Créer le composant NarratorBubble (AC: #1, #2, #3, #4, #5, #8, #9)
- Créer
frontend/app/components/feature/NarratorBubble.vue - Props : message (string), visible (boolean)
- Emit : close, skip
- Afficher l'avatar du Bug selon
narratorStagedu store - Intégrer le composable useTypewriter
- Bouton de fermeture/minimisation
- Utiliser font-narrative pour le texte
- Créer
-
Task 4: Implémenter l'accessibilité (AC: #6, #7)
- Ajouter
aria-live="polite"sur le conteneur - Ajouter
role="status"pour signaler les mises à jour - S'assurer que le texte complet est accessible même pendant l'animation
- Tester avec prefers-reduced-motion
- Ajouter
-
Task 5: Animation d'apparition/disparition (AC: #9)
- Slide-up pour l'apparition
- Fade-out pour la disparition
- Utiliser CSS transitions pour fluidité
- Non-bloquante : ne pas empêcher les interactions avec le reste de la page
-
Task 6: Responsive design (AC: #1)
- Desktop : bulle en bas de l'écran (position fixed)
- Mobile : au-dessus de la bottom bar (variable CSS pour le spacing)
- Taille adaptée à l'écran
-
Task 7: Tests et validation
- Tester l'effet typewriter
- Tester le skip au clic/Espace
- Vérifier les 5 stades du Bug
- Valider l'accessibilité (screen reader)
- Tester prefers-reduced-motion
- Valider responsive (desktop/mobile)
Dev Notes
Composable useTypewriter
// 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
// 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
<!-- 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 :
{
"narrator": {
"clickToSkip": "Cliquez ou appuyez sur Espace pour passer"
}
}
en.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
<!-- 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 |