# Story 3.3: Textes narrateur contextuels et arc de révélation

Status: ready-for-dev

## Story

As a visiteur,
I want que le narrateur réagisse à mes actions et évolue visuellement,
so that l'expérience est personnalisée et le narrateur devient familier.

## Acceptance Criteria

1. **Given** le visiteur navigue sur le site **When** il effectue des actions clés **Then** le narrateur affiche un message d'accueil à l'arrivée (adapté au héros choisi)
2. **And** des messages de transition s'affichent entre les zones
3. **And** des encouragements apparaissent à 25%, 50%, 75% de progression
4. **And** des indices s'affichent si le visiteur semble inactif (> 30s sans action)
5. **And** un message spécial "Bienvenue à nouveau" s'affiche si progression existante détectée
6. **And** le message de déblocage du contact s'affiche après 2 zones visitées
7. **Given** le visiteur progresse dans l'exploration **When** le `completionPercent` atteint certains seuils **Then** le `narratorStage` du store est mis à jour (1→5)
8. **And** l'apparence du Bug évolue : silhouette sombre (1) → forme vague (2) → pattes visibles (3) → araignée reconnaissable (4) → mascotte complète révélée (5)
9. **And** le ton du narrateur évolue de mystérieux à complice

## Tasks / Subtasks

- [ ] **Task 1: Créer le composable useNarrator** (AC: #1, #2, #3, #4, #5, #6)
  - [ ] Créer `frontend/app/composables/useNarrator.ts`
  - [ ] Centraliser la logique d'affichage du narrateur
  - [ ] Exposer les méthodes : showIntro, showTransition, showEncouragement, showHint, showWelcomeBack, showContactUnlocked
  - [ ] Gérer la queue de messages (ne pas interrompre un message en cours)
  - [ ] Intégrer le composable useFetchNarratorText

- [ ] **Task 2: Implémenter les déclencheurs de transition** (AC: #2)
  - [ ] Déclencher sur navigation vers /projets (transition_projects)
  - [ ] Déclencher sur navigation vers /competences (transition_skills)
  - [ ] Déclencher sur navigation vers /temoignages (transition_testimonials)
  - [ ] Déclencher sur navigation vers /parcours (transition_journey)
  - [ ] Utiliser un plugin Nuxt ou watcher sur la route

- [ ] **Task 3: Implémenter la détection d'inactivité** (AC: #4)
  - [ ] Créer `frontend/app/composables/useIdleDetection.ts`
  - [ ] Détecter l'absence d'interaction > 30 secondes
  - [ ] Écouter mouse, keyboard, touch, scroll
  - [ ] Déclencher `showHint()` quand idle détecté
  - [ ] Ne pas répéter les hints trop souvent (cooldown de 2min)

- [ ] **Task 4: Implémenter les encouragements basés sur la progression** (AC: #3)
  - [ ] Watcher sur `completionPercent` du store
  - [ ] Déclencher à 25%, 50%, 75%
  - [ ] Garder en mémoire les seuils déjà atteints (ne pas répéter)

- [ ] **Task 5: Implémenter l'arc de révélation du Bug** (AC: #7, #8, #9)
  - [ ] Définir les seuils de progression pour chaque stage :
    - Stage 1 : 0-19%
    - Stage 2 : 20-39%
    - Stage 3 : 40-59%
    - Stage 4 : 60-79%
    - Stage 5 : 80-100%
  - [ ] Mettre à jour `narratorStage` dans le store
  - [ ] L'image du Bug se met à jour automatiquement via NarratorBubble

- [ ] **Task 6: Implémenter le message "Bienvenue à nouveau"** (AC: #5)
  - [ ] Détecter au chargement si `visitedSections` n'est pas vide (progression existante)
  - [ ] Afficher le message `welcome_back` dans ce cas
  - [ ] Sinon afficher le message `intro` normal

- [ ] **Task 7: Implémenter le message de déblocage contact** (AC: #6)
  - [ ] Watcher sur `contactUnlocked` du store
  - [ ] Quand passe à `true`, afficher `contact_unlocked`

- [ ] **Task 8: Intégrer dans le layout principal**
  - [ ] Ajouter le NarratorBubble dans default.vue ou adventure.vue
  - [ ] Initialiser useNarrator dans le layout
  - [ ] Gérer l'état visible/hidden du narrateur

- [ ] **Task 9: Tests et validation**
  - [ ] Tester le message d'accueil adapté au héros
  - [ ] Tester les transitions entre pages
  - [ ] Vérifier les encouragements à 25/50/75%
  - [ ] Tester la détection d'inactivité
  - [ ] Valider l'évolution du Bug (5 stages)
  - [ ] Tester le "Bienvenue à nouveau"

## Dev Notes

### Composable useNarrator

```typescript
// frontend/app/composables/useNarrator.ts
interface NarratorMessage {
  context: string
  priority: number
}

export function useNarrator() {
  const { fetchText } = useFetchNarratorText()
  const progressionStore = useProgressionStore()

  const isVisible = ref(false)
  const currentMessage = ref('')
  const messageQueue = ref<NarratorMessage[]>([])
  const isProcessing = ref(false)

  // Seuils d'encouragement déjà affichés
  const shownEncouragements = ref<Set<number>>(new Set())

  // Cooldown pour les hints
  const lastHintTime = ref(0)
  const HINT_COOLDOWN = 120000 // 2 minutes

  async function queueMessage(context: string, priority: number = 5) {
    messageQueue.value.push({ context, priority })
    messageQueue.value.sort((a, b) => b.priority - a.priority)

    if (!isProcessing.value) {
      processQueue()
    }
  }

  async function processQueue() {
    if (messageQueue.value.length === 0) {
      isProcessing.value = false
      return
    }

    isProcessing.value = true
    const next = messageQueue.value.shift()!

    try {
      const response = await fetchText(next.context, progressionStore.heroType)
      currentMessage.value = response.data.text
      isVisible.value = true
    } catch (error) {
      console.error('Failed to fetch narrator text:', error)
      processQueue() // Passer au suivant en cas d'erreur
    }
  }

  function hide() {
    isVisible.value = false
    // Attendre la fin de l'animation avant de traiter le suivant
    setTimeout(() => {
      processQueue()
    }, 300)
  }

  // === Méthodes publiques ===

  async function showIntro() {
    // Vérifier si le visiteur revient
    if (progressionStore.visitedSections.length > 0) {
      await queueMessage('welcome_back', 10)
    } else {
      await queueMessage('intro', 10)
    }
  }

  async function showTransition(zone: 'projects' | 'skills' | 'testimonials' | 'journey') {
    const contextMap = {
      projects: 'transition_projects',
      skills: 'transition_skills',
      testimonials: 'transition_testimonials',
      journey: 'transition_journey',
    }
    await queueMessage(contextMap[zone], 7)
  }

  async function showEncouragement(percent: number) {
    // Ne pas répéter les encouragements
    if (shownEncouragements.value.has(percent)) return

    let context: string | null = null
    if (percent >= 75 && !shownEncouragements.value.has(75)) {
      context = 'encouragement_75'
      shownEncouragements.value.add(75)
    } else if (percent >= 50 && !shownEncouragements.value.has(50)) {
      context = 'encouragement_50'
      shownEncouragements.value.add(50)
    } else if (percent >= 25 && !shownEncouragements.value.has(25)) {
      context = 'encouragement_25'
      shownEncouragements.value.add(25)
    }

    if (context) {
      await queueMessage(context, 5)
    }
  }

  async function showHint() {
    const now = Date.now()
    if (now - lastHintTime.value < HINT_COOLDOWN) return

    lastHintTime.value = now
    await queueMessage('hint', 3)
  }

  async function showContactUnlocked() {
    await queueMessage('contact_unlocked', 8)
  }

  async function showWelcomeBack() {
    await queueMessage('welcome_back', 10)
  }

  return {
    isVisible,
    currentMessage,
    hide,
    showIntro,
    showTransition,
    showEncouragement,
    showHint,
    showContactUnlocked,
    showWelcomeBack,
  }
}
```

### Composable useIdleDetection

```typescript
// frontend/app/composables/useIdleDetection.ts
export interface UseIdleDetectionOptions {
  timeout?: number // ms avant de considérer comme idle
  onIdle?: () => void
}

export function useIdleDetection(options: UseIdleDetectionOptions = {}) {
  const { timeout = 30000, onIdle } = options

  const isIdle = ref(false)
  let timeoutId: ReturnType<typeof setTimeout> | null = null

  function resetTimer() {
    isIdle.value = false
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
    timeoutId = setTimeout(() => {
      isIdle.value = true
      onIdle?.()
    }, timeout)
  }

  const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart']

  onMounted(() => {
    events.forEach(event => {
      window.addEventListener(event, resetTimer, { passive: true })
    })
    resetTimer() // Démarrer le timer
  })

  onUnmounted(() => {
    events.forEach(event => {
      window.removeEventListener(event, resetTimer)
    })
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
  })

  return { isIdle }
}
```

### Logique de l'arc de révélation (dans useProgressionStore)

```typescript
// Ajouter dans frontend/app/stores/progression.ts

// Seuils pour les stages du Bug
const NARRATOR_STAGE_THRESHOLDS = [0, 20, 40, 60, 80] // 5 stages

function calculateNarratorStage(percent: number): number {
  for (let i = NARRATOR_STAGE_THRESHOLDS.length - 1; i >= 0; i--) {
    if (percent >= NARRATOR_STAGE_THRESHOLDS[i]) {
      return i + 1 // Stages 1-5
    }
  }
  return 1
}

// Dans le store
export const useProgressionStore = defineStore('progression', () => {
  // ... autres propriétés existantes ...

  const narratorStage = computed(() => {
    return calculateNarratorStage(completionPercent.value)
  })

  return {
    // ... autres exports ...
    narratorStage,
  }
})
```

### Plugin de navigation pour les transitions

```typescript
// frontend/app/plugins/narrator-transitions.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  const narrator = useNarrator()
  const router = useRouter()
  const progressionStore = useProgressionStore()

  // Map des routes vers les contextes de transition
  const routeContextMap: Record<string, 'projects' | 'skills' | 'testimonials' | 'journey'> = {
    '/projets': 'projects',
    '/en/projects': 'projects',
    '/competences': 'skills',
    '/en/skills': 'skills',
    '/temoignages': 'testimonials',
    '/en/testimonials': 'testimonials',
    '/parcours': 'journey',
    '/en/journey': 'journey',
  }

  // Sections déjà annoncées (pour ne pas répéter)
  const announcedSections = new Set<string>()

  router.afterEach((to) => {
    const zone = routeContextMap[to.path]
    if (zone && !announcedSections.has(zone)) {
      announcedSections.add(zone)
      narrator.showTransition(zone)
    }
  })

  // Watcher sur completionPercent pour les encouragements
  watch(
    () => progressionStore.completionPercent,
    (percent) => {
      narrator.showEncouragement(percent)
    }
  )

  // Watcher sur contactUnlocked
  watch(
    () => progressionStore.contactUnlocked,
    (unlocked, wasUnlocked) => {
      if (unlocked && !wasUnlocked) {
        narrator.showContactUnlocked()
      }
    }
  )
})
```

### Intégration dans le layout

```vue
<!-- frontend/app/layouts/adventure.vue -->
<script setup lang="ts">
const narrator = useNarrator()

// Détection d'inactivité
useIdleDetection({
  timeout: 30000,
  onIdle: () => {
    narrator.showHint()
  }
})

// Afficher l'intro au montage
onMounted(() => {
  // Délai pour laisser la page se charger
  setTimeout(() => {
    narrator.showIntro()
  }, 1000)
})
</script>

<template>
  <div class="adventure-layout">
    <slot />

    <!-- Narrateur -->
    <NarratorBubble
      :message="narrator.currentMessage.value"
      :visible="narrator.isVisible.value"
      @close="narrator.hide()"
    />
  </div>
</template>
```

### Tableau des stages du Bug

| Stage | Progression | Apparence | Ton du narrateur |
|-------|-------------|-----------|------------------|
| 1 | 0-19% | Silhouette sombre floue | Mystérieux, énigmatique |
| 2 | 20-39% | Forme vague avec yeux brillants | Curieux, observateur |
| 3 | 40-59% | Pattes visibles, forme d'araignée | Encourageant, guide |
| 4 | 60-79% | Araignée reconnaissable | Amical, complice |
| 5 | 80-100% | Mascotte complète révélée | Chaleureux, félicitations |

### Dépendances

**Cette story nécessite :**
- Story 3.1 : API narrateur (contextes et textes)
- Story 3.2 : Composant NarratorBubble
- Story 1.6 : Store Pinia (pour progression et heroType)

**Cette story prépare pour :**
- Story 3.5 : Logique de progression (déclenche les messages)
- Story 4.2 : Intro narrative (utilise useNarrator)

### Project Structure Notes

**Fichiers à créer :**
```
frontend/app/
├── composables/
│   ├── useNarrator.ts                   # CRÉER
│   └── useIdleDetection.ts              # CRÉER
├── plugins/
│   └── narrator-transitions.client.ts   # CRÉER
└── layouts/
    └── adventure.vue                    # CRÉER ou MODIFIER
```

**Fichiers à modifier :**
```
frontend/app/stores/progression.ts       # AJOUTER narratorStage computed
```

### References

- [Source: docs/planning-artifacts/epics.md#Story-3.3]
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Revelation-Arc]
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Contexts]
- [Source: docs/brainstorming-gamification-2026-01-26.md#Arc-Revelation]

### Technical Requirements

| Requirement | Value | Source |
|-------------|-------|--------|
| Stages du Bug | 5 (silhouette → mascotte) | UX Spec |
| Seuils progression | 0/20/40/60/80% | Décision technique |
| Timeout inactivité | 30 secondes | Epics |
| Cooldown hints | 2 minutes | Décision technique |
| Contextes transitions | 4 zones principales | Epics |

## 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

