From 99fa61fcaa69df74f27845018861d17a1e4f728d Mon Sep 17 00:00:00 2001 From: skycel Date: Sat, 7 Feb 2026 03:04:07 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(frontend):=20syst=C3=A8me=20na?= =?UTF-8?q?rrateur=20contextuel=20avec=20arc=20de=20r=C3=A9v=C3=A9lation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...es-narrateur-contextuels-arc-revelation.md | 90 ++++++------- .../sprint-status.yaml | 2 +- frontend/app/composables/useIdleDetection.ts | 44 +++++++ frontend/app/composables/useNarrator.ts | 119 ++++++++++++++++++ frontend/app/layouts/adventure.vue | 40 ++++++ frontend/app/pages/competences.vue | 4 + frontend/app/pages/parcours.vue | 4 + frontend/app/pages/projets/[slug].vue | 4 + frontend/app/pages/projets/index.vue | 4 + frontend/app/pages/temoignages.vue | 4 + .../plugins/narrator-transitions.client.ts | 42 +++++++ frontend/app/stores/progression.ts | 21 ++-- 12 files changed, 324 insertions(+), 54 deletions(-) create mode 100644 frontend/app/composables/useIdleDetection.ts create mode 100644 frontend/app/composables/useNarrator.ts create mode 100644 frontend/app/layouts/adventure.vue create mode 100644 frontend/app/plugins/narrator-transitions.client.ts diff --git a/docs/implementation-artifacts/3-3-textes-narrateur-contextuels-arc-revelation.md b/docs/implementation-artifacts/3-3-textes-narrateur-contextuels-arc-revelation.md index 817c441..6576624 100644 --- a/docs/implementation-artifacts/3-3-textes-narrateur-contextuels-arc-revelation.md +++ b/docs/implementation-artifacts/3-3-textes-narrateur-contextuels-arc-revelation.md @@ -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 diff --git a/docs/implementation-artifacts/sprint-status.yaml b/docs/implementation-artifacts/sprint-status.yaml index 57cdf6b..54eff2c 100644 --- a/docs/implementation-artifacts/sprint-status.yaml +++ b/docs/implementation-artifacts/sprint-status.yaml @@ -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 diff --git a/frontend/app/composables/useIdleDetection.ts b/frontend/app/composables/useIdleDetection.ts new file mode 100644 index 0000000..5e3fab5 --- /dev/null +++ b/frontend/app/composables/useIdleDetection.ts @@ -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 | 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) } +} diff --git a/frontend/app/composables/useNarrator.ts b/frontend/app/composables/useNarrator.ts new file mode 100644 index 0000000..768e60f --- /dev/null +++ b/frontend/app/composables/useNarrator.ts @@ -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([]) +const isProcessing = ref(false) +const shownEncouragements = ref>(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 = { + 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, + } +} diff --git a/frontend/app/layouts/adventure.vue b/frontend/app/layouts/adventure.vue new file mode 100644 index 0000000..829e11f --- /dev/null +++ b/frontend/app/layouts/adventure.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/app/pages/competences.vue b/frontend/app/pages/competences.vue index 22adebe..7a85ea4 100644 --- a/frontend/app/pages/competences.vue +++ b/frontend/app/pages/competences.vue @@ -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() diff --git a/frontend/app/pages/parcours.vue b/frontend/app/pages/parcours.vue index 9a703c8..f1d01e0 100644 --- a/frontend/app/pages/parcours.vue +++ b/frontend/app/pages/parcours.vue @@ -49,6 +49,10 @@