Files
Portfolio-Game/docs/implementation-artifacts/3-7-navigation-mobile-chemin-libre-bottom-bar.md
skycel 64b1a33d10 feat(mobile): add BottomBar navigation and CheminLibre drawer (Story 3.7)
- Add ZoneCard component for zone display with status indicators
- Add CheminLibre drawer with vertical zone cards and path decoration
- Add BottomBar with Map, Progress, and Settings buttons
- Add ProgressDetail modal showing visited sections
- Add SettingsDrawer with language, consent, and reset options
- Add i18n translations for zone, cheminLibre, bottomBar, settings
- Add --bottom-bar-height CSS variable for spacing
- Modify layouts to include BottomBar on mobile (< 768px)
- Support safe-area-inset for iOS devices
- Touch targets minimum 48x48px for WCAG compliance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 04:29:55 +01:00

824 lines
26 KiB
Markdown

# Story 3.7: Navigation mobile - Chemin Libre et Bottom Bar
Status: review
## Story
As a visiteur mobile,
I want naviguer facilement avec une interface adaptée au tactile,
so that l'expérience reste immersive sur petit écran.
## Acceptance Criteria
1. **Given** le visiteur est sur mobile (< 768px) **When** il accède à la navigation **Then** le "Chemin Libre" affiche les zones en cards verticales scrollables (`ZoneCard`)
2. **And** chaque `ZoneCard` affiche : illustration, nom de la zone, statut (visité/nouveau/verrouillé)
3. **And** une ligne décorative relie les cards visuellement (effet chemin)
4. **And** un tap sur une zone navigue vers la section correspondante
5. **And** la zone Contact affiche un cadenas si `contactUnlocked` est `false`
6. **Given** la bottom bar mobile est affichée **When** le visiteur interagit **Then** 3 icônes sont accessibles : Carte (ouvre le Chemin Libre), Progression (affiche le %), Paramètres
7. **And** les touch targets font au minimum 48x48px
8. **And** la bottom bar est fixe et toujours visible
9. **And** le narrateur s'affiche au-dessus de la bottom bar quand actif
## Tasks / Subtasks
- [x] **Task 1: Créer le composant ZoneCard** (AC: #1, #2, #5)
- [x] Créer `frontend/app/components/feature/ZoneCard.vue`
- [x] Props : zone (MapZone), isVisited, isLocked, isCurrent
- [x] Afficher illustration, nom traduit, statut visuel
- [x] Icône cadenas si verrouillé
- [x] Badge "Nouveau" si non visité
- [x] Checkmark si visité
- [x] **Task 2: Créer le composant CheminLibre** (AC: #1, #3, #4)
- [x] Créer `frontend/app/components/feature/CheminLibre.vue`
- [x] Afficher les 5 zones en cards verticales
- [x] Ligne décorative reliant les cards (SVG ou CSS)
- [x] Scroll vertical natif
- [x] Gestion du tap pour navigation
- [x] **Task 3: Créer le composant BottomBar** (AC: #6, #7, #8)
- [x] Créer `frontend/app/components/layout/BottomBar.vue`
- [x] 3 boutons : Carte, Progression, Paramètres
- [x] Touch targets minimum 48x48px
- [x] Position fixe en bas
- [x] Variable CSS --bottom-bar-height pour le spacing
- [x] **Task 4: Intégrer le drawer Chemin Libre** (AC: #1)
- [x] Au tap sur Carte dans BottomBar, ouvrir le CheminLibre
- [x] Le CheminLibre s'affiche en slide-up depuis le bas
- [x] Overlay pour fermer en tapant à l'extérieur
- [x] Handle de glissement pour fermer
- [x] **Task 5: Intégrer le modal Progression** (AC: #6)
- [x] Au tap sur Progression, afficher le détail
- [x] Réutiliser le composant ProgressBar avec compact mode
- [x] Afficher la liste des sections visitées/restantes
- [x] **Task 6: Intégrer les paramètres** (AC: #6)
- [x] Au tap sur Paramètres, ouvrir un drawer
- [x] Options : langue, mode Express (lien CV), réinitialiser
- [x] Consentement RGPD accessible
- [x] **Task 7: Gérer le positionnement du narrateur** (AC: #9)
- [x] Variable CSS --bottom-bar-height définie
- [x] Le NarratorBubble utilise cette variable pour son bottom
- [x] Pas de chevauchement entre narrateur et bottom bar
- [x] **Task 8: Responsive design**
- [x] BottomBar visible uniquement < 768px
- [x] CheminLibre adapté aux petits écrans
- [x] Safe-area-inset pour les appareils avec notch
- [x] **Task 9: Tests et validation**
- [x] Build validé sans erreurs
- [x] Touch targets 48px minimum (min-w-12 min-h-12)
- [x] Navigation entre zones fonctionnelle
- [x] Drawer Chemin Libre avec slide-up animation
- [x] Narrateur positionné au-dessus de la bottom bar
## Dev Notes
### Composant ZoneCard
```vue
<!-- frontend/app/components/feature/ZoneCard.vue -->
<script setup lang="ts">
import type { MapZone } from '~/data/mapZones'
const props = defineProps<{
zone: MapZone
isVisited: boolean
isLocked: boolean
isCurrent: boolean
}>()
const emit = defineEmits<{
select: []
}>()
const { locale, t } = useI18n()
const zoneName = computed(() => {
return locale.value === 'fr' ? props.zone.label.fr : props.zone.label.en
})
const statusText = computed(() => {
if (props.isLocked) return t('zone.locked')
if (props.isVisited) return t('zone.visited')
return t('zone.new')
})
const statusClass = computed(() => {
if (props.isLocked) return 'text-gray-500'
if (props.isVisited) return 'text-green-400'
return 'text-sky-accent'
})
</script>
<template>
<button
type="button"
class="zone-card relative w-full flex items-center gap-4 p-4 bg-sky-dark-50 rounded-xl border border-sky-dark-100 transition-all active:scale-98"
:class="[
isCurrent && 'ring-2 ring-sky-accent',
isLocked && 'opacity-60',
]"
:disabled="isLocked"
@click="emit('select')"
>
<!-- Illustration / Icône -->
<div
class="shrink-0 w-16 h-16 rounded-lg flex items-center justify-center text-3xl"
:style="{ backgroundColor: `${zone.color}20` }"
>
<template v-if="isLocked">
🔒
</template>
<template v-else>
<img
v-if="zone.icon.startsWith('/')"
:src="zone.icon"
:alt="zoneName"
class="w-10 h-10"
/>
<span v-else>{{ zone.icon }}</span>
</template>
</div>
<!-- Contenu -->
<div class="flex-1 text-left">
<h3 class="font-ui font-semibold text-sky-text text-lg">
{{ zoneName }}
</h3>
<p class="text-sm" :class="statusClass">
{{ statusText }}
</p>
</div>
<!-- Indicateurs -->
<div class="shrink-0">
<!-- Checkmark si visité -->
<span
v-if="isVisited && !isLocked"
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-green-500/20 text-green-400"
>
</span>
<!-- Badge "Nouveau" si non visité et non verrouillé -->
<span
v-else-if="!isVisited && !isLocked"
class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-sky-accent/20 text-sky-accent text-xs font-ui font-medium"
>
{{ t('zone.newBadge') }}
</span>
<!-- Cadenas si verrouillé -->
<span
v-else-if="isLocked"
class="inline-flex items-center justify-center w-8 h-8 text-gray-500"
>
🔒
</span>
</div>
</button>
</template>
<style scoped>
.zone-card:active {
transform: scale(0.98);
}
.zone-card:disabled {
cursor: not-allowed;
}
</style>
```
### Composant CheminLibre
```vue
<!-- frontend/app/components/feature/CheminLibre.vue -->
<script setup lang="ts">
import { mapZones } from '~/data/mapZones'
const emit = defineEmits<{
close: []
navigate: [route: string]
}>()
const { locale } = useI18n()
const router = useRouter()
const progressionStore = useProgressionStore()
function handleZoneSelect(zone: typeof mapZones[0]) {
if (zone.id === 'contact' && !progressionStore.contactUnlocked) {
return
}
const route = locale.value === 'fr' ? zone.route.fr : zone.route.en
router.push(route)
emit('navigate', route)
emit('close')
}
function isVisited(zoneId: string): boolean {
return zoneId !== 'contact' && progressionStore.visitedSections.includes(zoneId as any)
}
function isLocked(zoneId: string): boolean {
return zoneId === 'contact' && !progressionStore.contactUnlocked
}
function isCurrent(zoneId: string): boolean {
// Détecter basé sur la route actuelle
const route = useRoute()
const path = route.path.toLowerCase()
const routeMap: Record<string, string[]> = {
projets: ['projets', 'projects'],
competences: ['competences', 'skills'],
temoignages: ['temoignages', 'testimonials'],
parcours: ['parcours', 'journey'],
contact: ['contact'],
}
return routeMap[zoneId]?.some(segment => path.includes(segment)) ?? false
}
</script>
<template>
<div class="chemin-libre h-full overflow-y-auto pb-safe">
<!-- Header avec handle -->
<div class="sticky top-0 bg-sky-dark-50 pt-4 pb-2 z-10">
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4"></div>
<h2 class="text-xl font-ui font-bold text-sky-text text-center mb-4">
{{ $t('cheminLibre.title') }}
</h2>
</div>
<!-- Liste des zones avec ligne de connexion -->
<div class="relative px-4 pb-8">
<!-- Ligne de connexion verticale -->
<div class="absolute left-12 top-8 bottom-8 w-0.5 bg-sky-dark-100"></div>
<!-- Zones -->
<div class="space-y-4 relative z-10">
<div
v-for="(zone, index) in mapZones"
:key="zone.id"
class="relative"
>
<!-- Point sur la ligne -->
<div
class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 z-10"
:class="[
isVisited(zone.id) ? 'bg-green-400 border-green-400' : '',
isLocked(zone.id) ? 'bg-gray-500 border-gray-500' : '',
!isVisited(zone.id) && !isLocked(zone.id) ? 'bg-sky-dark border-sky-accent' : '',
]"
></div>
<!-- Card avec padding gauche pour la ligne -->
<div class="pl-12">
<ZoneCard
:zone="zone"
:is-visited="isVisited(zone.id)"
:is-locked="isLocked(zone.id)"
:is-current="isCurrent(zone.id)"
@select="handleZoneSelect(zone)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 1rem);
}
</style>
```
### Composant BottomBar
```vue
<!-- frontend/app/components/layout/BottomBar.vue -->
<script setup lang="ts">
const showCheminLibre = ref(false)
const showProgress = ref(false)
const showSettings = ref(false)
const progressionStore = useProgressionStore()
</script>
<template>
<div class="bottom-bar-container md:hidden">
<!-- Bottom Bar fixe -->
<nav
class="bottom-bar fixed bottom-0 inset-x-0 z-40 bg-sky-dark-50 border-t border-sky-dark-100 safe-bottom"
style="--bottom-bar-height: 64px"
>
<div class="flex items-center justify-around h-16">
<!-- Bouton Carte -->
<button
type="button"
class="bottom-bar-btn flex flex-col items-center justify-center min-w-12 min-h-12 p-2 text-sky-text-muted hover:text-sky-accent transition-colors"
:class="{ 'text-sky-accent': showCheminLibre }"
:aria-label="$t('bottomBar.map')"
@click="showCheminLibre = true"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span class="text-xs font-ui mt-1">{{ $t('bottomBar.map') }}</span>
</button>
<!-- Bouton Progression -->
<button
type="button"
class="bottom-bar-btn flex flex-col items-center justify-center min-w-12 min-h-12 p-2 text-sky-text-muted hover:text-sky-accent transition-colors"
:class="{ 'text-sky-accent': showProgress }"
:aria-label="$t('bottomBar.progress')"
@click="showProgress = true"
>
<!-- Cercle de progression -->
<div class="relative w-6 h-6">
<svg class="w-full h-full -rotate-90" viewBox="0 0 36 36">
<circle
cx="18" cy="18" r="14"
fill="none" stroke="currentColor" stroke-width="3"
class="text-sky-dark-100"
/>
<circle
cx="18" cy="18" r="14"
fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round"
class="text-sky-accent"
:stroke-dasharray="`${progressionStore.completionPercent}, 100`"
/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[8px] font-ui font-bold">
{{ progressionStore.completionPercent }}
</span>
</div>
<span class="text-xs font-ui mt-1">{{ $t('bottomBar.progress') }}</span>
</button>
<!-- Bouton Paramètres -->
<button
type="button"
class="bottom-bar-btn flex flex-col items-center justify-center min-w-12 min-h-12 p-2 text-sky-text-muted hover:text-sky-accent transition-colors"
:class="{ 'text-sky-accent': showSettings }"
:aria-label="$t('bottomBar.settings')"
@click="showSettings = true"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-xs font-ui mt-1">{{ $t('bottomBar.settings') }}</span>
</button>
</div>
</nav>
<!-- Drawer Chemin Libre -->
<Teleport to="body">
<Transition name="slide-up">
<div
v-if="showCheminLibre"
class="fixed inset-x-0 bottom-16 top-0 z-50"
>
<!-- Overlay -->
<div
class="absolute inset-0 bg-black/50"
@click="showCheminLibre = false"
></div>
<!-- Drawer content -->
<div class="absolute inset-x-0 bottom-0 max-h-[80vh] bg-sky-dark-50 rounded-t-2xl">
<CheminLibre @close="showCheminLibre = false" />
</div>
</div>
</Transition>
<!-- Modal Progression -->
<Transition name="fade">
<div
v-if="showProgress"
class="fixed inset-0 z-50 flex items-end justify-center md:items-center"
>
<div
class="absolute inset-0 bg-black/50"
@click="showProgress = false"
></div>
<div class="relative w-full max-w-sm bg-sky-dark-50 rounded-t-2xl md:rounded-2xl p-6 safe-bottom">
<ProgressDetail @close="showProgress = false" />
</div>
</div>
</Transition>
<!-- Drawer Paramètres -->
<Transition name="slide-up">
<div
v-if="showSettings"
class="fixed inset-x-0 bottom-16 top-0 z-50"
>
<div
class="absolute inset-0 bg-black/50"
@click="showSettings = false"
></div>
<div class="absolute inset-x-0 bottom-0 max-h-[60vh] bg-sky-dark-50 rounded-t-2xl p-6 safe-bottom">
<SettingsDrawer @close="showSettings = false" />
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.safe-bottom {
padding-bottom: max(env(safe-area-inset-bottom), 1rem);
}
.bottom-bar {
height: var(--bottom-bar-height, 64px);
}
.bottom-bar-btn {
min-width: 48px;
min-height: 48px;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
```
### Composant ProgressDetail (pour le modal)
```vue
<!-- frontend/app/components/feature/ProgressDetail.vue -->
<script setup lang="ts">
const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
const progressionStore = useProgressionStore()
const sections = computed(() => [
{ key: 'projets', name: t('progress.sections.projects'), visited: progressionStore.visitedSections.includes('projets') },
{ key: 'competences', name: t('progress.sections.skills'), visited: progressionStore.visitedSections.includes('competences') },
{ key: 'temoignages', name: t('progress.sections.testimonials'), visited: progressionStore.visitedSections.includes('temoignages') },
{ key: 'parcours', name: t('progress.sections.journey'), visited: progressionStore.visitedSections.includes('parcours') },
])
</script>
<template>
<div class="progress-detail">
<!-- Handle -->
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4 md:hidden"></div>
<h2 class="text-xl font-ui font-bold text-sky-text mb-4">
{{ t('progress.title') }}
</h2>
<!-- Barre de progression -->
<div class="mb-6">
<ProgressBar
:percent="progressionStore.completionPercent"
:show-tooltip="false"
compact
/>
</div>
<!-- Liste des sections -->
<ul class="space-y-3 mb-6">
<li
v-for="section in sections"
:key="section.key"
class="flex items-center gap-3"
>
<span
class="shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-sm"
:class="section.visited ? 'bg-green-500/20 text-green-400' : 'bg-sky-dark-100 text-sky-text-muted'"
>
{{ section.visited ? '✓' : '○' }}
</span>
<span :class="section.visited ? 'text-sky-text' : 'text-sky-text-muted'">
{{ section.name }}
</span>
</li>
</ul>
<!-- Bouton fermer -->
<button
type="button"
class="w-full py-3 bg-sky-dark-100 rounded-lg text-sky-text font-ui font-medium"
@click="emit('close')"
>
{{ t('common.close') }}
</button>
</div>
</template>
```
### Composant SettingsDrawer
```vue
<!-- frontend/app/components/feature/SettingsDrawer.vue -->
<script setup lang="ts">
const emit = defineEmits<{
close: []
}>()
const { locale, setLocale, t } = useI18n()
const progressionStore = useProgressionStore()
const consentStore = useConsentStore()
function toggleLanguage() {
setLocale(locale.value === 'fr' ? 'en' : 'fr')
}
function toggleExpressMode() {
progressionStore.setExpressMode(!progressionStore.expressMode)
}
function resetProgress() {
if (confirm(t('settings.confirmReset'))) {
progressionStore.reset()
localStorage.removeItem('skycel_progression')
}
}
</script>
<template>
<div class="settings-drawer">
<!-- Handle -->
<div class="w-12 h-1 bg-sky-dark-100 rounded-full mx-auto mb-4"></div>
<h2 class="text-xl font-ui font-bold text-sky-text mb-6">
{{ t('settings.title') }}
</h2>
<div class="space-y-4">
<!-- Langue -->
<div class="flex items-center justify-between py-3 border-b border-sky-dark-100">
<span class="text-sky-text">{{ t('settings.language') }}</span>
<button
type="button"
class="px-4 py-2 bg-sky-dark-100 rounded-lg text-sky-text font-ui"
@click="toggleLanguage"
>
{{ locale === 'fr' ? 'English' : 'Français' }}
</button>
</div>
<!-- Mode Express -->
<div class="flex items-center justify-between py-3 border-b border-sky-dark-100">
<div>
<span class="text-sky-text block">{{ t('settings.expressMode') }}</span>
<span class="text-xs text-sky-text-muted">{{ t('settings.expressModeDesc') }}</span>
</div>
<button
type="button"
class="w-12 h-6 rounded-full transition-colors"
:class="progressionStore.expressMode ? 'bg-sky-accent' : 'bg-sky-dark-100'"
@click="toggleExpressMode"
>
<span
class="block w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="progressionStore.expressMode ? 'translate-x-6' : 'translate-x-0.5'"
></span>
</button>
</div>
<!-- RGPD -->
<div class="flex items-center justify-between py-3 border-b border-sky-dark-100">
<div>
<span class="text-sky-text block">{{ t('settings.saveProgress') }}</span>
<span class="text-xs text-sky-text-muted">{{ t('settings.saveProgressDesc') }}</span>
</div>
<button
type="button"
class="w-12 h-6 rounded-full transition-colors"
:class="consentStore.hasConsent ? 'bg-sky-accent' : 'bg-sky-dark-100'"
@click="consentStore.hasConsent ? consentStore.revokeConsent() : consentStore.giveConsent()"
>
<span
class="block w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="consentStore.hasConsent ? 'translate-x-6' : 'translate-x-0.5'"
></span>
</button>
</div>
<!-- Réinitialiser -->
<button
type="button"
class="w-full py-3 bg-red-500/20 text-red-400 rounded-lg font-ui font-medium mt-4"
@click="resetProgress"
>
{{ t('settings.reset') }}
</button>
</div>
<!-- Fermer -->
<button
type="button"
class="w-full py-3 bg-sky-dark-100 rounded-lg text-sky-text font-ui font-medium mt-6"
@click="emit('close')"
>
{{ t('common.close') }}
</button>
</div>
</template>
```
### Clés i18n
**fr.json :**
```json
{
"zone": {
"locked": "Verrouillé",
"visited": "Visité",
"new": "À découvrir",
"newBadge": "Nouveau"
},
"cheminLibre": {
"title": "Chemin Libre"
},
"bottomBar": {
"map": "Carte",
"progress": "Progression",
"settings": "Options"
},
"settings": {
"title": "Paramètres",
"language": "Langue",
"expressMode": "Mode Express",
"expressModeDesc": "Navigation rapide sans aventure",
"saveProgress": "Sauvegarder ma progression",
"saveProgressDesc": "Permet de reprendre là où vous vous êtes arrêté",
"reset": "Réinitialiser ma progression",
"confirmReset": "Êtes-vous sûr de vouloir réinitialiser votre progression ?"
}
}
```
**en.json :**
```json
{
"zone": {
"locked": "Locked",
"visited": "Visited",
"new": "To discover",
"newBadge": "New"
},
"cheminLibre": {
"title": "Free Path"
},
"bottomBar": {
"map": "Map",
"progress": "Progress",
"settings": "Settings"
},
"settings": {
"title": "Settings",
"language": "Language",
"expressMode": "Express Mode",
"expressModeDesc": "Quick navigation without adventure",
"saveProgress": "Save my progress",
"saveProgressDesc": "Allows you to resume where you left off",
"reset": "Reset my progress",
"confirmReset": "Are you sure you want to reset your progress?"
}
}
```
### Variable CSS pour la Bottom Bar
```css
/* frontend/app/assets/css/main.css ou variables.css */
:root {
--bottom-bar-height: 64px;
}
/* Padding bottom pour le contenu principal sur mobile */
@media (max-width: 767px) {
.main-content {
padding-bottom: calc(var(--bottom-bar-height) + 1rem);
}
}
```
### Dépendances
**Cette story nécessite :**
- Story 3.4 : ProgressBar composant
- Story 3.5 : Store de progression
- Story 3.2 : NarratorBubble (pour le positionnement)
**Cette story prépare pour :**
- Story 4.2 : Intro narrative (navigation mobile)
- Epic 4 : Chemins narratifs (utilise la navigation)
### Project Structure Notes
**Fichiers à créer :**
```
frontend/app/components/
├── feature/
│ ├── ZoneCard.vue # CRÉER
│ ├── CheminLibre.vue # CRÉER
│ ├── ProgressDetail.vue # CRÉER
│ └── SettingsDrawer.vue # CRÉER
└── layout/
└── BottomBar.vue # CRÉER
```
**Fichiers à modifier :**
```
frontend/app/layouts/default.vue # AJOUTER BottomBar
frontend/app/assets/css/main.css # AJOUTER variables CSS
frontend/i18n/fr.json # AJOUTER traductions
frontend/i18n/en.json # AJOUTER traductions
```
### References
- [Source: docs/planning-artifacts/epics.md#Story-3.7]
- [Source: docs/planning-artifacts/ux-design-specification.md#Mobile-Navigation]
- [Source: docs/planning-artifacts/ux-design-specification.md#Bottom-Bar]
### Technical Requirements
| Requirement | Value | Source |
|-------------|-------|--------|
| Breakpoint mobile | < 768px | Epics |
| Touch targets | 48x48px minimum | WCAG |
| Bottom bar height | 64px | Décision technique |
| Safe area | env(safe-area-inset-bottom) | iOS |
## Dev Agent Record
### Agent Model Used
Claude Opus 4.5 (claude-opus-4-5-20251101)
### Debug Log References
- Build validé sans erreurs bloquantes
- Warning connu sur HeroType import dupliqué (non bloquant)
### Completion Notes List
- ZoneCard utilise `emoji` de MapZone (pas `icon` comme dans la spec originale)
- SettingsDrawer utilise un lien vers /resume au lieu d'un toggle expressMode
- ProgressDetail réutilise ProgressBar avec mode compact
- Toutes les transitions respectent prefers-reduced-motion
- Safe-area-inset géré pour iOS devices
### Change Log
| Date | Change | Author |
|------|--------|--------|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
| 2026-02-07 | Implémentation complète | Dev Agent |
### File List
**Fichiers créés :**
- `frontend/app/components/feature/ZoneCard.vue`
- `frontend/app/components/feature/CheminLibre.vue`
- `frontend/app/components/feature/ProgressDetail.vue`
- `frontend/app/components/feature/SettingsDrawer.vue`
- `frontend/app/components/layout/BottomBar.vue`
**Fichiers modifiés :**
- `frontend/app/layouts/default.vue` - Ajout BottomBar et padding mobile
- `frontend/app/layouts/adventure.vue` - Ajout BottomBar et padding mobile
- `frontend/app/assets/css/main.css` - Variable CSS --bottom-bar-height
- `frontend/i18n/fr.json` - Traductions zone, cheminLibre, bottomBar, settings
- `frontend/i18n/en.json` - Traductions zone, cheminLibre, bottomBar, settings