Files
Portfolio-Game/docs/implementation-artifacts/4-1-composant-choicecards-choix-narratifs.md
skycel 7e87a341a2 feat(epic-4): chemins narratifs, easter eggs, challenge et contact
Epic 4: Chemins Narratifs, Challenge & Contact

Stories implementees:
- 4.1: Composant ChoiceCards pour choix narratifs binaires
- 4.2: Sequence d'intro narrative avec Le Bug
- 4.3: Chemins narratifs differencies avec useNarrativePath
- 4.4: Table easter_eggs et systeme de detection (API + composable)
- 4.5: Easter eggs UI (popup, notification, collection)
- 4.6: Page challenge avec puzzle de code
- 4.7: Page revelation "Monde de Code"
- 4.8: Page contact avec formulaire et stats

Fichiers crees:
- Frontend: ChoiceCards, IntroSequence, ZoneEndChoice, EasterEggPopup,
  CodePuzzle, ChallengeSuccess, CodeWorld, et pages intro/challenge/revelation
- API: EasterEggController, Model, Migration, Seeder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 13:35:12 +01:00

12 KiB

Story 4.1: Composant ChoiceCards et choix narratifs

Status: done

Story

As a visiteur, I want faire des choix qui influencent mon parcours, so that mon expérience est unique et personnalisée.

Acceptance Criteria

  1. Given le composant ChoiceCards est implémenté When le narrateur propose un choix Then 2 cards s'affichent côte à côte (desktop) ou empilées (mobile)
  2. And chaque card affiche : icône, texte narratif du choix
  3. And un hover/focus highlight la card sélectionnable
  4. And un clic enregistre le choix dans choices du store Pinia
  5. And une transition animée mène vers la destination choisie
  6. And le composant est accessible (role="radiogroup", navigation clavier, focus visible)
  7. And prefers-reduced-motion simplifie les animations
  8. And le style est cohérent avec l'univers narratif (police serif, couleurs des zones)

Tasks / Subtasks

  • Task 1: Définir les types de choix (AC: #2, #4)

    • Créer frontend/app/types/choice.ts
    • Interface Choice : id, textFr, textEn, icon, destination, zoneColor
    • Interface ChoicePoint : id, choices (2 options), context
  • Task 2: Créer le composant ChoiceCard (AC: #2, #3, #8)

    • Créer frontend/app/components/feature/ChoiceCard.vue
    • Props : choice (Choice), selected (boolean), disabled (boolean)
    • Afficher icône + texte narratif
    • Effet hover/focus avec highlight
    • Police serif narrative pour le texte
  • Task 3: Créer le composant ChoiceCards (AC: #1, #4, #5, #6)

    • Créer frontend/app/components/feature/ChoiceCards.vue
    • Props : choicePoint (ChoicePoint)
    • Emit : select (choice)
    • Layout côte à côte desktop, empilé mobile
    • Gérer la sélection et enregistrer dans le store
    • Animation de transition vers la destination
  • Task 4: Implémenter l'accessibilité (AC: #6)

    • role="radiogroup" sur le conteneur
    • role="radio" sur chaque card
    • aria-checked pour indiquer la sélection
    • Navigation clavier (flèches gauche/droite)
    • Focus visible conforme WCAG
  • Task 5: Gérer les animations (AC: #5, #7)

    • Animation de sélection (scale + glow)
    • Transition vers la destination (fade-out)
    • Respecter prefers-reduced-motion
  • Task 6: Intégrer avec le store (AC: #4)

    • Appeler progressionStore.makeChoice(id, value) à la sélection
    • Les choix sont persistés avec le reste de la progression
  • Task 7: Tests et validation

    • Build production réussi
    • Validation TypeScript des composants

Dev Notes

Types des choix

// frontend/app/types/choice.ts
export interface Choice {
  id: string
  textFr: string
  textEn: string
  icon: string // emoji ou URL d'image
  destination: string // route vers laquelle naviguer
  zoneColor: string // couleur de la zone associée
}

export interface ChoicePoint {
  id: string
  questionFr: string
  questionEn: string
  choices: [Choice, Choice] // Toujours 2 choix binaires
  context: string // contexte narratif (intro, after_projects, etc.)
}

// Exemple de point de choix
export const CHOICE_POINTS: Record<string, ChoicePoint> = {
  intro_first_choice: {
    id: 'intro_first_choice',
    questionFr: 'Par où veux-tu commencer ton exploration ?',
    questionEn: 'Where do you want to start your exploration?',
    choices: [
      {
        id: 'choice_projects_first',
        textFr: 'Découvrir les créations',
        textEn: 'Discover the creations',
        icon: '💻',
        destination: '/projets',
        zoneColor: '#3b82f6',
      },
      {
        id: 'choice_skills_first',
        textFr: 'Explorer les compétences',
        textEn: 'Explore the skills',
        icon: '⚡',
        destination: '/competences',
        zoneColor: '#10b981',
      },
    ],
    context: 'intro',
  },
  after_projects: {
    id: 'after_projects',
    questionFr: 'Quelle sera ta prochaine étape ?',
    questionEn: 'What will be your next step?',
    choices: [
      {
        id: 'choice_testimonials',
        textFr: "Écouter ceux qui l'ont rencontré",
        textEn: 'Listen to those who met him',
        icon: '💬',
        destination: '/temoignages',
        zoneColor: '#f59e0b',
      },
      {
        id: 'choice_journey',
        textFr: 'Suivre son parcours',
        textEn: 'Follow his journey',
        icon: '📍',
        destination: '/parcours',
        zoneColor: '#8b5cf6',
      },
    ],
    context: 'after_projects',
  },
}

Composant ChoiceCard

<!-- frontend/app/components/feature/ChoiceCard.vue -->
<script setup lang="ts">
import type { Choice } from '~/types/choice'

const props = defineProps<{
  choice: Choice
  selected: boolean
  disabled: boolean
}>()

const emit = defineEmits<{
  select: []
}>()

const reducedMotion = useReducedMotion()
const { locale } = useI18n()

const text = computed(() => {
  return locale.value === 'fr' ? props.choice.textFr : props.choice.textEn
})
</script>

<template>
  <button
    type="button"
    class="choice-card relative flex flex-col items-center p-6 rounded-xl border-2 transition-all duration-300 focus:outline-none"
    :class="[
      selected
        ? 'border-sky-accent bg-sky-accent/10 scale-105 shadow-lg shadow-sky-accent/20'
        : 'border-sky-dark-100 bg-sky-dark-50 hover:border-sky-accent/50 hover:bg-sky-dark-50/80',
      disabled && 'opacity-50 cursor-not-allowed',
      !reducedMotion && 'transform',
    ]"
    :style="{ '--zone-color': choice.zoneColor }"
    :disabled="disabled"
    :aria-checked="selected"
    role="radio"
    @click="emit('select')"
  >
    <!-- Glow effect au hover -->
    <div
      class="absolute inset-0 rounded-xl opacity-0 transition-opacity pointer-events-none"
      :class="!selected && 'group-hover:opacity-100'"
      :style="{ boxShadow: `0 0 30px ${choice.zoneColor}40` }"
    ></div>

    <!-- Icône -->
    <div
      class="w-16 h-16 rounded-full flex items-center justify-center text-4xl mb-4"
      :style="{ backgroundColor: `${choice.zoneColor}20` }"
    >
      {{ choice.icon }}
    </div>

    <!-- Texte narratif -->
    <p class="font-narrative text-lg text-sky-text text-center leading-relaxed">
      {{ text }}
    </p>

    <!-- Indicateur de sélection -->
    <div
      v-if="selected"
      class="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-sky-accent flex items-center justify-center"
    >
      <span class="text-white text-sm"></span>
    </div>
  </button>
</template>

<style scoped>
.choice-card:focus-visible {
  outline: 2px solid var(--sky-accent);
  outline-offset: 4px;
}

.choice-card:not(:disabled):hover {
  transform: translateY(-4px);
}

@media (prefers-reduced-motion: reduce) {
  .choice-card {
    transition: none;
    transform: none !important;
  }

  .choice-card:not(:disabled):hover {
    transform: none;
  }
}
</style>

Composant ChoiceCards

<!-- frontend/app/components/feature/ChoiceCards.vue -->
<script setup lang="ts">
import type { ChoicePoint, Choice } from '~/types/choice'

const props = defineProps<{
  choicePoint: ChoicePoint
}>()

const emit = defineEmits<{
  selected: [choice: Choice]
}>()

const { locale } = useI18n()
const router = useRouter()
const progressionStore = useProgressionStore()
const reducedMotion = useReducedMotion()

const selectedChoice = ref<Choice | null>(null)
const isTransitioning = ref(false)

const question = computed(() => {
  return locale.value === 'fr' ? props.choicePoint.questionFr : props.choicePoint.questionEn
})

function handleSelect(choice: Choice) {
  if (isTransitioning.value) return

  selectedChoice.value = choice

  // Enregistrer le choix dans le store
  progressionStore.addChoice(props.choicePoint.id, choice.id)

  // Émettre l'événement
  emit('selected', choice)

  // Animation puis navigation
  isTransitioning.value = true

  const delay = reducedMotion.value ? 100 : 800
  setTimeout(() => {
    const route = locale.value === 'fr' ? choice.destination : `/en${choice.destination}`
    router.push(route)
  }, delay)
}

// Navigation clavier
function handleKeydown(e: KeyboardEvent) {
  const choices = props.choicePoint.choices
  const currentIndex = selectedChoice.value
    ? choices.findIndex(c => c.id === selectedChoice.value?.id)
    : -1

  if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
    e.preventDefault()
    const newIndex = currentIndex <= 0 ? choices.length - 1 : currentIndex - 1
    handleSelect(choices[newIndex])
  } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
    e.preventDefault()
    const newIndex = currentIndex >= choices.length - 1 ? 0 : currentIndex + 1
    handleSelect(choices[newIndex])
  }
}
</script>

<template>
  <div
    class="choice-cards-container"
    :class="{ 'transitioning': isTransitioning }"
  >
    <!-- Question du narrateur -->
    <p class="font-narrative text-xl text-sky-text text-center mb-8 italic">
      {{ question }}
    </p>

    <!-- Cards de choix -->
    <div
      class="choice-cards grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl mx-auto"
      role="radiogroup"
      :aria-label="question"
      @keydown="handleKeydown"
    >
      <ChoiceCard
        v-for="choice in choicePoint.choices"
        :key="choice.id"
        :choice="choice"
        :selected="selectedChoice?.id === choice.id"
        :disabled="isTransitioning"
        @select="handleSelect(choice)"
      />
    </div>
  </div>
</template>

<style scoped>
.choice-cards-container.transitioning {
  animation: fadeOut 0.5s ease-out forwards;
}

@keyframes fadeOut {
  from {
    opacity: 1;
    transform: scale(1);
  }
  to {
    opacity: 0;
    transform: scale(0.95);
  }
}

@media (prefers-reduced-motion: reduce) {
  .choice-cards-container.transitioning {
    animation: fadeOutSimple 0.1s ease-out forwards;
  }

  @keyframes fadeOutSimple {
    from { opacity: 1; }
    to { opacity: 0; }
  }
}
</style>

Utilisation dans une page/composant

<!-- Exemple d'utilisation -->
<script setup>
import { CHOICE_POINTS } from '~/types/choice'

const currentChoicePoint = ref(CHOICE_POINTS.intro_first_choice)

function handleChoiceSelected(choice) {
  console.log('Choice selected:', choice.id)
  // La navigation est gérée automatiquement par ChoiceCards
}
</script>

<template>
  <div class="min-h-screen flex items-center justify-center p-8">
    <ChoiceCards
      :choice-point="currentChoicePoint"
      @selected="handleChoiceSelected"
    />
  </div>
</template>

Dépendances

Cette story nécessite :

  • Story 3.5 : Store de progression (addChoice)
  • Story 3.2 : useReducedMotion composable

Cette story prépare pour :

  • Story 4.2 : Intro narrative (utilise ChoiceCards)
  • Story 4.3 : Chemins narratifs (points de choix multiples)

Project Structure Notes

Fichiers à créer :

frontend/app/
├── types/
│   └── choice.ts                        # CRÉER
└── components/feature/
    ├── ChoiceCard.vue                   # CRÉER
    └── ChoiceCards.vue                  # CRÉER

References

  • [Source: docs/planning-artifacts/epics.md#Story-4.1]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Choice-System]
  • [Source: docs/brainstorming-gamification-2026-01-26.md#Parcours-Narratifs]

Technical Requirements

Requirement Value Source
Choix par point 2 (binaire) Epics
Layout desktop Côte à côte Epics
Layout mobile Empilé Epics
Accessibilité role="radiogroup", clavier Epics
Police font-narrative UX Spec

Dev Agent Record

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

Debug Log References

Completion Notes List

  • Composants ChoiceCard et ChoiceCards créés avec accessibilité complète
  • Types Choice et ChoicePoint définis avec CHOICE_POINTS prédéfinis
  • Intégration store via progressionStore.makeChoice()
  • Animations avec respect prefers-reduced-motion
  • Build production validé

Change Log

Date Change Author
2026-02-04 Story créée avec contexte complet SM Agent
2026-02-08 Implémentation complète des composants Dev Agent

File List

  • frontend/app/types/choice.ts (CREATED)
  • frontend/app/components/feature/ChoiceCard.vue (CREATED)
  • frontend/app/components/feature/ChoiceCards.vue (CREATED)