🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
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>
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
# Story 3.2: Composant NarratorBubble (Le Bug)
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur,
|
||||
I want voir un narrateur-guide qui m'accompagne dans mon exploration,
|
||||
so that je me sens guidé et l'expérience est immersive.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le composant `NarratorBubble` est implémenté **When** le narrateur doit afficher un message **Then** une bulle apparaît en bas de l'écran (desktop) ou au-dessus de la bottom bar (mobile)
|
||||
2. **And** l'avatar du Bug (araignée) s'affiche avec son apparence selon le `narratorStage` du store
|
||||
3. **And** le texte apparaît avec effet typewriter (lettre par lettre)
|
||||
4. **And** un clic ou Espace accélère l'animation typewriter
|
||||
5. **And** la bulle peut être fermée/minimisée sans bloquer la navigation
|
||||
6. **And** le composant utilise `aria-live="polite"` et `role="status"` pour l'accessibilité
|
||||
7. **And** `prefers-reduced-motion` affiche le texte instantanément
|
||||
8. **And** la police serif narrative est utilisée pour le texte
|
||||
9. **And** l'animation d'apparition/disparition est fluide et non-bloquante
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer le composable useTypewriter** (AC: #3, #4, #7)
|
||||
- [ ] Créer `frontend/app/composables/useTypewriter.ts`
|
||||
- [ ] Accepter le texte en paramètre
|
||||
- [ ] Afficher lettre par lettre (30-50ms par lettre)
|
||||
- [ ] Exposer une méthode `skip()` pour afficher tout le texte instantanément
|
||||
- [ ] Respecter `prefers-reduced-motion`
|
||||
|
||||
- [ ] **Task 2: Créer les assets du Bug par stage** (AC: #2)
|
||||
- [ ] Préparer 5 images SVG ou PNG pour les 5 stades du Bug
|
||||
- [ ] Stage 1 : silhouette sombre floue
|
||||
- [ ] Stage 2 : forme vague avec yeux
|
||||
- [ ] Stage 3 : pattes visibles
|
||||
- [ ] Stage 4 : araignée reconnaissable
|
||||
- [ ] Stage 5 : mascotte complète révélée
|
||||
- [ ] Placer dans `frontend/public/images/bug/`
|
||||
|
||||
- [ ] **Task 3: Créer le composant NarratorBubble** (AC: #1, #2, #3, #4, #5, #8, #9)
|
||||
- [ ] Créer `frontend/app/components/feature/NarratorBubble.vue`
|
||||
- [ ] Props : message (string), visible (boolean)
|
||||
- [ ] Emit : close, skip
|
||||
- [ ] Afficher l'avatar du Bug selon `narratorStage` du store
|
||||
- [ ] Intégrer le composable useTypewriter
|
||||
- [ ] Bouton de fermeture/minimisation
|
||||
- [ ] Utiliser font-narrative pour le texte
|
||||
|
||||
- [ ] **Task 4: Implémenter l'accessibilité** (AC: #6, #7)
|
||||
- [ ] Ajouter `aria-live="polite"` sur le conteneur
|
||||
- [ ] Ajouter `role="status"` pour signaler les mises à jour
|
||||
- [ ] S'assurer que le texte complet est accessible même pendant l'animation
|
||||
- [ ] Tester avec prefers-reduced-motion
|
||||
|
||||
- [ ] **Task 5: Animation d'apparition/disparition** (AC: #9)
|
||||
- [ ] Slide-up pour l'apparition
|
||||
- [ ] Fade-out pour la disparition
|
||||
- [ ] Utiliser CSS transitions pour fluidité
|
||||
- [ ] Non-bloquante : ne pas empêcher les interactions avec le reste de la page
|
||||
|
||||
- [ ] **Task 6: Responsive design** (AC: #1)
|
||||
- [ ] Desktop : bulle en bas de l'écran (position fixed)
|
||||
- [ ] Mobile : au-dessus de la bottom bar (variable CSS pour le spacing)
|
||||
- [ ] Taille adaptée à l'écran
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester l'effet typewriter
|
||||
- [ ] Tester le skip au clic/Espace
|
||||
- [ ] Vérifier les 5 stades du Bug
|
||||
- [ ] Valider l'accessibilité (screen reader)
|
||||
- [ ] Tester prefers-reduced-motion
|
||||
- [ ] Valider responsive (desktop/mobile)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Composable useTypewriter
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useTypewriter.ts
|
||||
export interface UseTypewriterOptions {
|
||||
speed?: number // ms par caractère
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function useTypewriter(options: UseTypewriterOptions = {}) {
|
||||
const { speed = 40, onComplete } = options
|
||||
|
||||
const text = ref('')
|
||||
const displayedText = ref('')
|
||||
const isTyping = ref(false)
|
||||
const isComplete = ref(false)
|
||||
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let currentIndex = 0
|
||||
|
||||
function start(newText: string) {
|
||||
text.value = newText
|
||||
displayedText.value = ''
|
||||
currentIndex = 0
|
||||
isTyping.value = true
|
||||
isComplete.value = false
|
||||
|
||||
// Si prefers-reduced-motion, afficher tout instantanément
|
||||
if (reducedMotion.value) {
|
||||
skip()
|
||||
return
|
||||
}
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
if (currentIndex < text.value.length) {
|
||||
displayedText.value += text.value[currentIndex]
|
||||
currentIndex++
|
||||
} else {
|
||||
complete()
|
||||
}
|
||||
}, speed)
|
||||
}
|
||||
|
||||
function skip() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
displayedText.value = text.value
|
||||
complete()
|
||||
}
|
||||
|
||||
function complete() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
isTyping.value = false
|
||||
isComplete.value = true
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
text.value = ''
|
||||
displayedText.value = ''
|
||||
currentIndex = 0
|
||||
isTyping.value = false
|
||||
isComplete.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
text,
|
||||
displayedText,
|
||||
isTyping,
|
||||
isComplete,
|
||||
start,
|
||||
skip,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Composable useReducedMotion
|
||||
|
||||
```typescript
|
||||
// frontend/app/composables/useReducedMotion.ts
|
||||
export function useReducedMotion() {
|
||||
const reducedMotion = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
reducedMotion.value = mediaQuery.matches
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
reducedMotion.value = e.matches
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handler)
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery.removeEventListener('change', handler)
|
||||
})
|
||||
})
|
||||
|
||||
return reducedMotion
|
||||
}
|
||||
```
|
||||
|
||||
### Composant NarratorBubble
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/NarratorBubble.vue -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const progressionStore = useProgressionStore()
|
||||
const { displayedText, isTyping, isComplete, start, skip } = useTypewriter({
|
||||
speed: 40,
|
||||
})
|
||||
|
||||
// Images du Bug par stage
|
||||
const bugImages: Record<number, string> = {
|
||||
1: '/images/bug/bug-stage-1.svg',
|
||||
2: '/images/bug/bug-stage-2.svg',
|
||||
3: '/images/bug/bug-stage-3.svg',
|
||||
4: '/images/bug/bug-stage-4.svg',
|
||||
5: '/images/bug/bug-stage-5.svg',
|
||||
}
|
||||
|
||||
const currentBugImage = computed(() => {
|
||||
return bugImages[progressionStore.narratorStage] || bugImages[1]
|
||||
})
|
||||
|
||||
// Démarrer l'animation quand le message change
|
||||
watch(() => props.message, (newMessage) => {
|
||||
if (newMessage && props.visible) {
|
||||
start(newMessage)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Écouter les clics et touches pour skip
|
||||
function handleInteraction() {
|
||||
if (isTyping.value) {
|
||||
skip()
|
||||
emit('skip')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInteraction()
|
||||
}
|
||||
if (e.code === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="narrator-slide">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="narrator-bubble fixed bottom-4 left-4 right-4 md:left-auto md:right-8 md:max-w-md z-50"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@click="handleInteraction"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-start gap-4 bg-sky-dark-50 rounded-xl p-4 shadow-xl border border-sky-dark-100">
|
||||
<!-- Avatar du Bug -->
|
||||
<div class="shrink-0 w-16 h-16 md:w-20 md:h-20">
|
||||
<img
|
||||
:src="currentBugImage"
|
||||
:alt="`Le Bug - Stade ${progressionStore.narratorStage}`"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Texte avec typewriter -->
|
||||
<p class="font-narrative text-sky-text text-base md:text-lg leading-relaxed">
|
||||
{{ displayedText }}
|
||||
<span
|
||||
v-if="isTyping"
|
||||
class="inline-block w-0.5 h-5 bg-sky-accent animate-blink ml-0.5"
|
||||
></span>
|
||||
</p>
|
||||
|
||||
<!-- Texte complet pour screen readers (caché visuellement) -->
|
||||
<span class="sr-only">{{ message }}</span>
|
||||
|
||||
<!-- Indicateur de skip -->
|
||||
<p
|
||||
v-if="isTyping"
|
||||
class="text-xs text-sky-text-muted mt-2 font-ui"
|
||||
>
|
||||
{{ $t('narrator.clickToSkip') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bouton fermer -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1 text-sky-text-muted hover:text-sky-text transition-colors"
|
||||
:aria-label="$t('common.close')"
|
||||
@click.stop="emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.narrator-slide-enter-active,
|
||||
.narrator-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.narrator-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.narrator-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
/* Position mobile : au-dessus de la bottom bar */
|
||||
@media (max-width: 767px) {
|
||||
.narrator-bubble {
|
||||
bottom: calc(var(--bottom-bar-height, 64px) + 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prefers reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.narrator-slide-enter-active,
|
||||
.narrator-slide-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n à ajouter
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"narrator": {
|
||||
"clickToSkip": "Cliquez ou appuyez sur Espace pour passer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"narrator": {
|
||||
"clickToSkip": "Click or press Space to skip"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Structure des assets du Bug
|
||||
|
||||
```
|
||||
frontend/public/images/bug/
|
||||
├── bug-stage-1.svg # Silhouette sombre floue
|
||||
├── bug-stage-2.svg # Forme vague avec yeux
|
||||
├── bug-stage-3.svg # Pattes visibles
|
||||
├── bug-stage-4.svg # Araignée reconnaissable
|
||||
└── bug-stage-5.svg # Mascotte complète révélée
|
||||
```
|
||||
|
||||
### Utilisation du composant
|
||||
|
||||
```vue
|
||||
<!-- Exemple d'utilisation dans un layout ou page -->
|
||||
<script setup>
|
||||
const showNarrator = ref(true)
|
||||
const narratorMessage = ref('')
|
||||
|
||||
const { fetchText } = useFetchNarratorText()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
async function showIntro() {
|
||||
const response = await fetchText('intro', progressionStore.heroType)
|
||||
narratorMessage.value = response.data.text
|
||||
showNarrator.value = true
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showNarrator.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NarratorBubble
|
||||
:message="narratorMessage"
|
||||
:visible="showNarrator"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.1 : API narrateur pour les textes
|
||||
- Story 1.6 : Store Pinia (pour narratorStage)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.3 : Textes contextuels (utilise ce composant)
|
||||
- Story 3.5 : Logique de progression (déclenche le narrateur)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── components/feature/
|
||||
│ └── NarratorBubble.vue # CRÉER
|
||||
├── composables/
|
||||
│ ├── useTypewriter.ts # CRÉER
|
||||
│ └── useReducedMotion.ts # CRÉER
|
||||
└── public/images/bug/
|
||||
├── bug-stage-1.svg # CRÉER (asset)
|
||||
├── bug-stage-2.svg # CRÉER (asset)
|
||||
├── bug-stage-3.svg # CRÉER (asset)
|
||||
├── bug-stage-4.svg # CRÉER (asset)
|
||||
└── bug-stage-5.svg # CRÉER (asset)
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/i18n/fr.json # AJOUTER narrator.clickToSkip
|
||||
frontend/i18n/en.json # AJOUTER narrator.clickToSkip
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.2]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#NarratorBubble]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Revelation-Arc]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Mascotte-Le-Bug]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Effect typewriter | 30-50ms par lettre | Epics |
|
||||
| Stades du Bug | 5 apparences distinctes | UX Spec |
|
||||
| Position desktop | Bottom fixed | Epics |
|
||||
| Position mobile | Au-dessus bottom bar | Epics |
|
||||
| Accessibilité | aria-live + role="status" | Epics |
|
||||
| Police | font-narrative | UX Spec |
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user