Files
Portfolio-Game/docs/implementation-artifacts/1-5-landing-page-choix-heros.md
skycel dc3456bb1b 🎮 Add landing page with hero selection (Story 1.5)
Landing page with animated tagline, dual CTA (adventure/express), and
HeroSelector component (3 heroes: Recruiter, Client, Developer) with full
keyboard accessibility (radiogroup, arrow nav, Enter confirm). Staggered
CSS animations respecting prefers-reduced-motion. Bilingual FR/EN.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:17:01 +01:00

15 KiB

Story 1.5: Landing page et choix du héros

Status: review

Story

As a visiteur, I want choisir entre l'aventure et le mode express, puis sélectionner mon héros, so that mon expérience est adaptée à mon profil et mon temps disponible.

Acceptance Criteria

  1. Given le visiteur arrive sur la landing page (/) When la page se charge Then deux CTA distincts sont visibles : "Partir à l'aventure" et "Mode express"
  2. And un texte d'accroche intrigant bilingue est affiché
  3. And une animation d'entrée subtile est présente (respectant prefers-reduced-motion)
  4. And le design est responsive (mobile + desktop)
  5. And au clic sur "Partir à l'aventure", le composant HeroSelector s'affiche avec 3 cards illustrées (Recruteur, Client, Développeur) avec nom et description courte
  6. And le héros sélectionné est stocké dans le store Pinia useProgressionStore (champ hero)
  7. And au clic sur "Mode express", le visiteur est redirigé vers la page résumé
  8. And le HeroSelector est accessible au clavier (role="radiogroup", flèches pour naviguer, Enter pour sélectionner)

Tasks / Subtasks

  • Task 1: Structure de la landing page (AC: #1, #2, #4)

    • Implémenter frontend/app/pages/index.vue
    • Section hero avec texte d'accroche bilingue ($t('landing.title'), $t('landing.subtitle'))
    • Deux boutons CTA côte à côte (desktop) ou empilés (mobile)
    • Utiliser les couleurs du design system (sky-accent pour CTA principal)
    • Layout responsive : centré verticalement, max-width pour le contenu
  • Task 2: Animations d'entrée (AC: #3)

    • Animation fade-in + slide-up pour le texte d'accroche
    • Animation staggered pour les CTA (apparition décalée)
    • Utiliser CSS animations ou GSAP (lazy-loaded)
    • Media query prefers-reduced-motion : animations désactivées
    • Durée totale : ~1s max
  • Task 3: Composant HeroSelector (AC: #5, #8)

    • Créer frontend/app/components/feature/HeroSelector.vue
    • Props : modelValue (héros sélectionné), emit update:modelValue
    • Afficher 3 cards : Recruteur, Client, Développeur
    • Chaque card : illustration/icône, nom traduit, description courte traduite
    • État visuel : card sélectionnée avec bordure accent
    • Accessibilité : role="radiogroup", role="radio" sur chaque card
    • Navigation clavier : flèches gauche/droite, Enter pour confirmer
    • Focus visible sur la card active
  • Task 4: Données des héros (AC: #5)

    • Définir les 3 héros dans un fichier de config ou composable
    • Structure : { id: 'recruteur' | 'client' | 'dev', nameKey, descriptionKey, icon }
    • Traductions dans i18n/fr.json et i18n/en.json :
      • hero.recruteur.name: "Recruteur"
      • hero.recruteur.description: "Je cherche un talent pour mon équipe"
      • hero.client.name: "Client"
      • hero.client.description: "J'ai un projet à réaliser"
      • hero.dev.name: "Développeur"
      • hero.dev.description: "Je suis curieux de voir ton travail"
  • Task 5: Intégration avec le store Pinia (AC: #6)

    • Importer useProgressionStore (créé en Story 1.6, mais interface définie ici)
    • Au choix du héros : store.setHero(heroId)
    • Après sélection : naviguer vers la première zone ou afficher l'intro narrative
    • Si store non disponible (Story 1.6 pas encore faite) : utiliser un state local temporaire
  • Task 6: Flow de sélection (AC: #5, #6)

    • État initial : CTA visibles, HeroSelector masqué
    • Clic "Partir à l'aventure" : transition vers HeroSelector (fade/slide)
    • Clic sur un héros : sélection visuelle
    • Bouton "Confirmer" ou double-clic : valider et naviguer
    • Bouton "Retour" pour revenir aux CTA
    • Animation de transition fluide entre les états
  • Task 7: Redirection Mode Express (AC: #7)

    • Clic "Mode express" : navigateTo(localePath('/resume'))
    • Pas de sélection de héros requise pour le mode express
    • Le store peut rester sans héros défini (mode anonyme)
  • Task 8: SEO et meta tags (AC: #1)

    • Utiliser useSeo() pour définir les meta tags de la landing
    • Title : "Skycel - Portfolio interactif de Célian"
    • Description : "Découvrez mon portfolio gamifié..."
    • Open Graph image : image de preview attractive
  • Task 9: Validation finale (AC: tous)

    • Page accessible en FR (/) et EN (/en)
    • Textes traduits correctement
    • CTA fonctionnels
    • HeroSelector s'affiche et fonctionne
    • Navigation clavier complète
    • Animations fluides (et désactivées si reduced-motion)
    • Responsive : mobile et desktop
    • Redirection vers /resume fonctionne

Dev Notes

Structure de la landing page

┌─────────────────────────────────────────────────────────────────┐
│                         LANDING PAGE                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│                    [Logo ou titre animé]                        │
│                                                                 │
│              "Bienvenue dans mon univers"                       │
│                   Développeur Full-Stack                        │
│                                                                 │
│     ┌─────────────────┐    ┌─────────────────┐                 │
│     │  Partir à       │    │   Mode express  │                 │
│     │  l'aventure     │    │   (30 secondes) │                 │
│     └─────────────────┘    └─────────────────┘                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

                              ↓ Clic "Aventure"

┌─────────────────────────────────────────────────────────────────┐
│                       HERO SELECTOR                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│                  "Qui êtes-vous, voyageur ?"                    │
│                                                                 │
│   ┌─────────┐    ┌─────────┐    ┌─────────┐                    │
│   │  👔     │    │  💼     │    │  💻     │                    │
│   │Recruteur│    │ Client  │    │   Dev   │                    │
│   │ "Je..." │    │ "J'ai..." │   │"Je suis.."│                  │
│   └─────────┘    └─────────┘    └─────────┘                    │
│                                                                 │
│              [Retour]              [Confirmer]                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Composant HeroSelector

<!-- frontend/app/components/feature/HeroSelector.vue -->
<template>
  <div class="hero-selector">
    <h2 class="text-2xl font-narrative text-center mb-8">
      {{ $t('hero.question') }}
    </h2>

    <div
      role="radiogroup"
      :aria-label="$t('hero.select_label')"
      class="grid grid-cols-1 md:grid-cols-3 gap-6"
      @keydown="handleKeydown"
    >
      <button
        v-for="(hero, index) in heroes"
        :key="hero.id"
        role="radio"
        :aria-checked="modelValue === hero.id"
        :tabindex="modelValue === hero.id || (!modelValue && index === 0) ? 0 : -1"
        :class="[
          'hero-card p-6 rounded-xl border-2 transition-all duration-200',
          'focus:outline-none focus:ring-2 focus:ring-sky-accent focus:ring-offset-2 focus:ring-offset-sky-dark',
          modelValue === hero.id
            ? 'border-sky-accent bg-sky-dark-50'
            : 'border-sky-text/20 hover:border-sky-text/40'
        ]"
        @click="selectHero(hero.id)"
        @keydown.enter="confirmSelection"
      >
        <div class="text-4xl mb-4">{{ hero.icon }}</div>
        <h3 class="text-xl font-ui font-semibold mb-2">
          {{ $t(hero.nameKey) }}
        </h3>
        <p class="text-sky-text/70 font-narrative text-sm">
          {{ $t(hero.descriptionKey) }}
        </p>
      </button>
    </div>

    <div class="flex justify-center gap-4 mt-8">
      <button
        class="px-6 py-2 text-sky-text/70 hover:text-sky-text transition-colors"
        @click="$emit('back')"
      >
        {{ $t('common.back') }}
      </button>
      <button
        :disabled="!modelValue"
        :class="[
          'px-8 py-3 rounded-lg font-ui font-semibold transition-all',
          modelValue
            ? 'bg-sky-accent text-sky-dark hover:bg-sky-accent-hover'
            : 'bg-sky-text/20 text-sky-text/50 cursor-not-allowed'
        ]"
        @click="confirmSelection"
      >
        {{ $t('common.continue') }}
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
type HeroType = 'recruteur' | 'client' | 'dev'

interface Hero {
  id: HeroType
  nameKey: string
  descriptionKey: string
  icon: string
}

const props = defineProps<{
  modelValue: HeroType | null
}>()

const emit = defineEmits<{
  'update:modelValue': [value: HeroType]
  'confirm': []
  'back': []
}>()

const heroes: Hero[] = [
  { id: 'recruteur', nameKey: 'hero.recruteur.name', descriptionKey: 'hero.recruteur.description', icon: '👔' },
  { id: 'client', nameKey: 'hero.client.name', descriptionKey: 'hero.client.description', icon: '💼' },
  { id: 'dev', nameKey: 'hero.dev.name', descriptionKey: 'hero.dev.description', icon: '💻' },
]

const selectHero = (id: HeroType) => {
  emit('update:modelValue', id)
}

const confirmSelection = () => {
  if (props.modelValue) {
    emit('confirm')
  }
}

const handleKeydown = (e: KeyboardEvent) => {
  const currentIndex = heroes.findIndex(h => h.id === props.modelValue)
  let newIndex = currentIndex

  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
    newIndex = (currentIndex + 1) % heroes.length
  } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
    newIndex = (currentIndex - 1 + heroes.length) % heroes.length
  }

  if (newIndex !== currentIndex) {
    emit('update:modelValue', heroes[newIndex].id)
  }
}
</script>

Traductions à ajouter

// frontend/i18n/fr.json
{
  "landing": {
    "title": "Bienvenue dans mon univers",
    "subtitle": "Développeur Full-Stack passionné",
    "cta_adventure": "Partir à l'aventure",
    "cta_express": "Mode express (30s)"
  },
  "hero": {
    "question": "Qui êtes-vous, voyageur ?",
    "select_label": "Sélectionnez votre profil",
    "recruteur": {
      "name": "Recruteur",
      "description": "Je cherche un talent pour rejoindre mon équipe"
    },
    "client": {
      "name": "Client",
      "description": "J'ai un projet à réaliser et je cherche le bon développeur"
    },
    "dev": {
      "name": "Développeur",
      "description": "Je suis curieux de découvrir ton travail et tes compétences"
    }
  }
}

Animations CSS

/* Animations d'entrée pour la landing */
@keyframes fadeSlideUp {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-fade-slide-up {
  animation: fadeSlideUp 0.6s ease-out forwards;
}

.animate-delay-100 { animation-delay: 0.1s; }
.animate-delay-200 { animation-delay: 0.2s; }
.animate-delay-300 { animation-delay: 0.3s; }

/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .animate-fade-slide-up {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Dépendances

Cette story DÉPEND de :

  • Story 1.3 : Système i18n pour les traductions
  • Story 1.4 : Layout default, transitions de page, useSeo()

Cette story PRÉPARE pour :

  • Story 1.6 : Le store Pinia stockera le héros sélectionné
  • Story 4.2 : L'intro narrative suivra la sélection du héros

Note : Si Story 1.6 n'est pas encore implémentée, utiliser un state local (ref) comme placeholder.

References

  • [Source: docs/planning-artifacts/epics.md#Story-1.5]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Hero-System]
  • [Source: docs/planning-artifacts/architecture.md#Store-Pinia]
  • [Source: docs/prd-gamification.md#FR1]

Technical Requirements

Requirement Value Source
Héros disponibles Recruteur, Client, Dev UX Design
Accessibilité WCAG AA, keyboard nav NFR6
Animations Respecter reduced-motion NFR6
Responsive Mobile + Desktop NFR3

Dev Agent Record

Agent Model Used

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

Debug Log References

  • Aucun problème majeur rencontré. Le HeroSelector est rendu côté client (interactif), pas dans le HTML SSR initial.

Completion Notes List

  • Landing page avec accroche bilingue, animations fade-slide-up staggered, responsive
  • HeroSelector accessible (role="radiogroup", navigation clavier flèches+Enter, focus management)
  • 3 héros : Recruteur, Client, Développeur avec icônes emoji et traductions FR/EN
  • Flow : CTA → Transition hero → Sélection → Confirm → Navigation vers /projets
  • Mode express : NuxtLink vers /resume (pas de sélection de héros)
  • Intégration store Pinia préparée (commentaire placeholder pour Story 1.6)
  • prefers-reduced-motion respecté pour toutes les animations
  • SEO meta tags sur la landing via useSeo()

Change Log

Date Change Author
2026-02-03 Story créée avec contexte complet SM Agent
2026-02-05 Tasks 1-9 implémentées et validées Dev Agent (Claude Opus 4.5)

File List

  • frontend/app/pages/index.vue — MODIFIÉ (landing complète avec flow HeroSelector)
  • frontend/app/components/feature/HeroSelector.vue — CRÉÉ
  • frontend/app/assets/css/transitions.css — MODIFIÉ (animations landing + hero transition)
  • frontend/i18n/fr.json — MODIFIÉ (ajout hero.*, landing.subtitle/cta_express)
  • frontend/i18n/en.json — MODIFIÉ (ajout hero.*, landing.subtitle/cta_express)