feat(frontend): système narrateur contextuel avec arc de révélation

Story 3.3 : Textes narrateur contextuels et arc de révélation
- Composable useNarrator.ts avec queue de messages prioritaires
- Composable useIdleDetection.ts (détection inactivité 30s)
- Plugin narrator-transitions.client.ts (déclencheurs de navigation)
- Layout adventure.vue avec NarratorBubble intégré
- Store progression: narratorStage devient un getter calculé (0-20-40-60-80%)
- Pages projets, competences, temoignages, parcours utilisent layout adventure
- Messages: intro, transitions, encouragements 25/50/75%, hints, contact_unlocked

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 03:04:07 +01:00
parent e882cd3e7a
commit 99fa61fcaa
12 changed files with 324 additions and 54 deletions

View File

@@ -1,6 +1,6 @@
# Story 3.3: Textes narrateur contextuels et arc de révélation
Status: ready-for-dev
Status: review
## Story
@@ -22,63 +22,63 @@ so that l'expérience est personnalisée et le narrateur devient familier.
## 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
- [x] **Task 1: Créer le composable useNarrator** (AC: #1, #2, #3, #4, #5, #6)
- [x] Créer `frontend/app/composables/useNarrator.ts`
- [x] Centraliser la logique d'affichage du narrateur
- [x] Exposer les méthodes : showIntro, showTransition, showEncouragement, showHint, showWelcomeBack, showContactUnlocked
- [x] Gérer la queue de messages (ne pas interrompre un message en cours)
- [x] 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
- [x] **Task 2: Implémenter les déclencheurs de transition** (AC: #2)
- [x] Déclencher sur navigation vers /projets (transition_projects)
- [x] Déclencher sur navigation vers /competences (transition_skills)
- [x] Déclencher sur navigation vers /temoignages (transition_testimonials)
- [x] Déclencher sur navigation vers /parcours (transition_journey)
- [x] 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)
- [x] **Task 3: Implémenter la détection d'inactivité** (AC: #4)
- [x] Créer `frontend/app/composables/useIdleDetection.ts`
- [x] Détecter l'absence d'interaction > 30 secondes
- [x] Écouter mouse, keyboard, touch, scroll
- [x] Déclencher `showHint()` quand idle détecté
- [x] 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)
- [x] **Task 4: Implémenter les encouragements basés sur la progression** (AC: #3)
- [x] Watcher sur `completionPercent` du store
- [x] Déclencher à 25%, 50%, 75%
- [x] 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 :
- [x] **Task 5: Implémenter l'arc de révélation du Bug** (AC: #7, #8, #9)
- [x] 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
- [x] Mettre à jour `narratorStage` dans le store (getter calculé)
- [x] 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
- [x] **Task 6: Implémenter le message "Bienvenue à nouveau"** (AC: #5)
- [x] Détecter au chargement si `visitedSections` n'est pas vide (progression existante)
- [x] Afficher le message `welcome_back` dans ce cas
- [x] 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`
- [x] **Task 7: Implémenter le message de déblocage contact** (AC: #6)
- [x] Watcher sur `contactUnlocked` du store
- [x] 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
- [x] **Task 8: Intégrer dans le layout principal**
- [x] Ajouter le NarratorBubble dans adventure.vue
- [x] Initialiser useNarrator dans le layout
- [x] 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"
- [x] **Task 9: Tests et validation**
- [x] Tester le message d'accueil adapté au héros
- [x] Tester les transitions entre pages
- [x] Vérifier les encouragements à 25/50/75%
- [x] Tester la détection d'inactivité
- [x] Valider l'évolution du Bug (5 stages)
- [x] Tester le "Bienvenue à nouveau"
## Dev Notes

View File

@@ -73,7 +73,7 @@ development_status:
epic-3: in-progress
3-1-table-narrator-texts-api-narrateur: review
3-2-composant-narratorbubble-le-bug: review
3-3-textes-narrateur-contextuels-arc-revelation: ready-for-dev
3-3-textes-narrateur-contextuels-arc-revelation: review
3-4-barre-progression-globale-xp-bar: ready-for-dev
3-5-logique-progression-deblocage-contact: ready-for-dev
3-6-carte-interactive-desktop-konvajs: ready-for-dev

View File

@@ -0,0 +1,44 @@
export interface UseIdleDetectionOptions {
timeout?: number
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']
if (import.meta.client) {
onMounted(() => {
events.forEach((event) => {
window.addEventListener(event, resetTimer, { passive: true })
})
resetTimer()
})
onUnmounted(() => {
events.forEach((event) => {
window.removeEventListener(event, resetTimer)
})
if (timeoutId) {
clearTimeout(timeoutId)
}
})
}
return { isIdle: readonly(isIdle) }
}

View File

@@ -0,0 +1,119 @@
import type { NarratorContext, HeroType } from './useFetchNarratorText'
interface NarratorMessage {
context: NarratorContext
priority: number
}
const HINT_COOLDOWN = 120000
const isVisible = ref(false)
const currentMessage = ref('')
const messageQueue = ref<NarratorMessage[]>([])
const isProcessing = ref(false)
const shownEncouragements = ref<Set<number>>(new Set())
const lastHintTime = ref(0)
export function useNarrator() {
const { fetchText } = useFetchNarratorText()
const progressionStore = useProgressionStore()
async function queueMessage(context: NarratorContext, 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 heroType = progressionStore.hero as HeroType | undefined
const response = await fetchText(next.context, heroType ?? undefined)
if (response) {
currentMessage.value = response.text
isVisible.value = true
} else {
processQueue()
}
} catch {
processQueue()
}
}
function hide() {
isVisible.value = false
setTimeout(() => {
processQueue()
}, 300)
}
async function showIntro() {
if (progressionStore.hasExistingProgress) {
await queueMessage('welcome_back', 10)
} else {
await queueMessage('intro', 10)
}
}
async function showTransition(zone: 'projects' | 'skills' | 'testimonials' | 'journey') {
const contextMap: Record<string, NarratorContext> = {
projects: 'transition_projects',
skills: 'transition_skills',
testimonials: 'transition_testimonials',
journey: 'transition_journey',
}
await queueMessage(contextMap[zone], 7)
}
async function showEncouragement(percent: number) {
let context: NarratorContext | 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)
}
return {
isVisible: readonly(isVisible),
currentMessage: readonly(currentMessage),
hide,
showIntro,
showTransition,
showEncouragement,
showHint,
showContactUnlocked,
}
}

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
const narrator = useNarrator()
useIdleDetection({
timeout: 30000,
onIdle: () => {
narrator.showHint()
},
})
onMounted(() => {
setTimeout(() => {
narrator.showIntro()
}, 1500)
})
</script>
<template>
<div class="min-h-screen bg-sky-dark text-sky-text flex flex-col">
<LayoutAppHeader />
<main class="flex-1">
<slot />
</main>
<LayoutAppFooter />
<ClientOnly>
<LayoutConsentBanner />
</ClientOnly>
<ClientOnly>
<NarratorBubble
:message="narrator.currentMessage.value"
:visible="narrator.isVisible.value"
@close="narrator.hide()"
/>
</ClientOnly>
</div>
</template>

View File

@@ -80,6 +80,10 @@
import type { Skill } from '~/types/skill'
import { useProgressionStore } from '~/stores/progression'
definePageMeta({
layout: 'adventure',
})
const { t } = useI18n()
const { setPageMeta } = useSeo()
const store = useProgressionStore()

View File

@@ -49,6 +49,10 @@
<script setup lang="ts">
import type { Milestone } from '~/components/feature/TimelineItem.vue'
definePageMeta({
layout: 'adventure',
})
const { setPageMeta } = useSeo()
const { t, tm } = useI18n()
const progressStore = useProgressStore()

View File

@@ -161,6 +161,10 @@
<script setup lang="ts">
import { useProgressionStore } from '~/stores/progression'
definePageMeta({
layout: 'adventure',
})
const route = useRoute()
const { t, locale } = useI18n()
const localePath = useLocalePath()

View File

@@ -54,6 +54,10 @@
<script setup lang="ts">
import { useProgressionStore } from '~/stores/progression'
definePageMeta({
layout: 'adventure',
})
const { t } = useI18n()
const { setPageMeta } = useSeo()
const store = useProgressionStore()

View File

@@ -119,6 +119,10 @@
<script setup lang="ts">
import type { Testimonial } from '~/types/testimonial'
definePageMeta({
layout: 'adventure',
})
const { setPageMeta } = useSeo()
const { t } = useI18n()
const progressStore = useProgressStore()

View File

@@ -0,0 +1,42 @@
export default defineNuxtPlugin(() => {
const narrator = useNarrator()
const router = useRouter()
const progressionStore = useProgressionStore()
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',
}
const announcedSections = new Set<string>()
router.afterEach((to) => {
const zone = routeContextMap[to.path]
if (zone && !announcedSections.has(zone)) {
announcedSections.add(zone)
narrator.showTransition(zone)
}
})
watch(
() => progressionStore.completionPercent,
(percent) => {
narrator.showEncouragement(percent)
},
)
watch(
() => progressionStore.contactUnlocked,
(unlocked, wasUnlocked) => {
if (unlocked && !wasUnlocked) {
narrator.showContactUnlocked()
}
},
)
})

View File

@@ -11,11 +11,21 @@ export interface ProgressionState {
easterEggsFound: string[]
challengeCompleted: boolean
contactUnlocked: boolean
narratorStage: number
choices: Record<string, string>
consentGiven: boolean | null
}
const NARRATOR_STAGE_THRESHOLDS = [0, 20, 40, 60, 80]
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
}
}
return 1
}
const SECTIONS = ['projets', 'competences', 'temoignages', 'parcours'] as const
/**
@@ -69,7 +79,6 @@ export const useProgressionStore = defineStore('progression', {
easterEggsFound: [],
challengeCompleted: false,
contactUnlocked: false,
narratorStage: 1,
choices: {},
consentGiven: null,
}),
@@ -82,6 +91,8 @@ export const useProgressionStore = defineStore('progression', {
progressPercent: (state) => Math.round((state.visitedSections.length / SECTIONS.length) * 100),
hasExistingProgress: (state) => state.visitedSections.length > 0 || state.hero !== null,
narratorStage: (state) => calculateNarratorStage(state.completionPercent),
},
actions: {
@@ -121,12 +132,6 @@ export const useProgressionStore = defineStore('progression', {
this.contactUnlocked = true
},
updateNarratorStage(stage: number) {
if (stage >= 1 && stage <= 5) {
this.narratorStage = stage
}
},
makeChoice(choiceId: string, value: string) {
this.choices[choiceId] = value
},