# Story 4.1: Composant ChoiceCards et choix narratifs

Status: ready-for-dev

## 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.addChoice(id, value)` à la sélection
  - [ ] Les choix sont persistés avec le reste de la progression

- [ ] **Task 7: Tests et validation**
  - [ ] Tester le layout desktop et mobile
  - [ ] Valider hover/focus
  - [ ] Tester navigation clavier
  - [ ] Vérifier l'enregistrement du choix
  - [ ] Tester prefers-reduced-motion

## Dev Notes

### Types des choix

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

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

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

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

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

