Files
Portfolio-Game/docs/implementation-artifacts/3-2-composant-narratorbubble-le-bug.md
skycel ec1ae92799 🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 02:08:56 +01:00

495 lines
13 KiB
Markdown

# Story 3.2: Composant NarratorBubble (Le Bug)
Status: ready-for-dev
## 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
- [ ] **Task 1: Créer le composable useTypewriter** (AC: #3, #4, #7)
- [ ] Créer `frontend/app/composables/useTypewriter.ts`
- [ ] 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`
- [ ] **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 `narratorStage` du store
- [ ] Intégrer le composable useTypewriter
- [ ] Bouton de fermeture/minimisation
- [ ] Utiliser font-narrative pour le texte
- [ ] **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
- [ ] **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
```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