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

@@ -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
},