Files
Portfolio-Game/docs/implementation-artifacts/3-2-composant-narratorbubble-le-bug.md
skycel ec1ae92799 🎉 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>
2026-02-05 02:08:56 +01:00

13 KiB

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

// 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

// 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

<!-- 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 :

{
  "narrator": {
    "clickToSkip": "Cliquez ou appuyez sur Espace pour passer"
  }
}

en.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

<!-- 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