# 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

