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

26 KiB

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

  • Task 1: Créer le composant ZoneCard (AC: #1, #2, #5)

    • Créer frontend/app/components/feature/ZoneCard.vue
    • Props : zone (MapZone), isVisited, isLocked, isCurrent
    • Afficher illustration, nom traduit, statut visuel
    • Icône cadenas si verrouillé
    • Badge "Nouveau" si non visité
    • Checkmark si visité
  • Task 2: Créer le composant CheminLibre (AC: #1, #3, #4)

    • Créer frontend/app/components/feature/CheminLibre.vue
    • Afficher les 5 zones en cards verticales
    • Ligne décorative reliant les cards (SVG ou CSS)
    • Scroll vertical natif
    • Gestion du tap pour navigation
  • Task 3: Créer le composant BottomBar (AC: #6, #7, #8)

    • Créer frontend/app/components/layout/BottomBar.vue
    • 3 boutons : Carte, Progression, Paramètres
    • Touch targets minimum 48x48px
    • Position fixe en bas
    • Variable CSS --bottom-bar-height pour le spacing
  • Task 4: Intégrer le drawer Chemin Libre (AC: #1)

    • Au tap sur Carte dans BottomBar, ouvrir le CheminLibre
    • Le CheminLibre s'affiche en slide-up depuis le bas
    • Overlay pour fermer en tapant à l'extérieur
    • Handle de glissement pour fermer
  • Task 5: Intégrer le modal Progression (AC: #6)

    • Au tap sur Progression, afficher le détail
    • Réutiliser le composant ProgressBar avec compact mode
    • Afficher la liste des sections visitées/restantes
  • Task 6: Intégrer les paramètres (AC: #6)

    • Au tap sur Paramètres, ouvrir un drawer
    • Options : langue, mode Express (lien CV), réinitialiser
    • Consentement RGPD accessible
  • Task 7: Gérer le positionnement du narrateur (AC: #9)

    • Variable CSS --bottom-bar-height définie
    • Le NarratorBubble utilise cette variable pour son bottom
    • Pas de chevauchement entre narrateur et bottom bar
  • Task 8: Responsive design

    • BottomBar visible uniquement < 768px
    • CheminLibre adapté aux petits écrans
    • Safe-area-inset pour les appareils avec notch
  • Task 9: Tests et validation

    • Build validé sans erreurs
    • Touch targets 48px minimum (min-w-12 min-h-12)
    • Navigation entre zones fonctionnelle
    • Drawer Chemin Libre avec slide-up animation
    • Narrateur positionné au-dessus de la bottom bar

Dev Notes

Composant ZoneCard

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

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

<!-- 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)

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

<!-- 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 :

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

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

/* 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