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>
12 KiB
12 KiB
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
- Given le composant
ChoiceCardsest implémenté When le narrateur propose un choix Then 2 cards s'affichent côte à côte (desktop) ou empilées (mobile) - And chaque card affiche : icône, texte narratif du choix
- And un hover/focus highlight la card sélectionnable
- And un clic enregistre le choix dans
choicesdu store Pinia - And une transition animée mène vers la destination choisie
- And le composant est accessible (
role="radiogroup", navigation clavier, focus visible) - And
prefers-reduced-motionsimplifie les animations - 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
- Créer
-
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
- Créer
-
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
- Créer
-
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
- Appeler
-
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
// 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
{{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 |