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>
462 lines
14 KiB
Markdown
462 lines
14 KiB
Markdown
# 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
|
|
|