world-game/PLAN_NAVIGATION_SLIDES.md
2026-02-19 11:58:19 +01:00

11 KiB
Raw Blame History

Plan : Navigation par slides horizontaux

Contexte

Le site source (world.game) utilise fullpage.js v4 avec un plugin scrollHorizontally propriétaire. Toutes les pages sont dans le DOM simultanément et la navigation fait glisser un conteneur avec transform: translate3d. On reproduit ce comportement en Svelte 5 sans dépendance externe.

Approche retenue :

  • Charger d'abord la page demandée (chargement initial rapide)
  • Charger les autres en arrière-plan en parallèle (HTTP/2, payloads légers)
  • Si l'utilisateur navigue avant qu'une page soit chargée → attendre qu'elle le soit
  • Hack back/forward inspiré de decarb.one

Pourquoi le chargement progressif plutôt qu'un endpoint unique /all-pages.json ? Un endpoint unique bloquerait le premier rendu jusqu'à ce que Kirby ait tout construit. Le chargement progressif affiche la page initiale immédiatement, les 5 autres chargent en parallèle. Avec HTTP/2 et des payloads JSON légers (6 pages), le coût est négligeable. Chaque page peut aussi être mise en cache indépendamment.


Ordre des slides

Dynamique, tiré de site->pages->listed (Kirby). Ne pas hardcoder l'ordre.

Au chargement initial, la réponse JSON d'une page inclut data.site.navigation (déjà exposé par le template Kirby et stocké dans site.svelte.js). Ce tableau donne l'ordre et les chemins des pages listées. C'est lui qui construit le tableau SLIDES_CONFIG au runtime.

Exemple de ce que Kirby expose déjà :

{
  "site": {
    "navigation": [
      { "id": "home", "url": "/home", "title": "Home" },
      { "id": "expertise", "url": "/expertise", "title": "Expertise" },
      ...
    ]
  }
}

Le store slides sera initialisé depuis ce tableau dynamiquement après le premier fetch.


Architecture cible

Conteneur slides (App.svelte)

[body: overflow hidden]
  [Header - fixed]
  [.slides-wrapper: display flex, width: N*100vw, transition 1000ms]
    [.slide × N: width 100vw, height 100vh, flex-shrink 0]
      [<ViewComponent data={...} />]  ← rendu seulement si data chargée

transform: translateX(-{activeIndex * 100}vw) géré via style inline réactif.


Fichiers à créer

src/state/slides.svelte.js (nouveau)

Store central de l'état des slides :

// Initialisé dynamiquement depuis site.navigation
// slidesData: Array<{ id, path, template, data, loaded, loading }>
// activeIndex: number
// pendingPath: string | null

export const slides = {
  get all(), get activeIndex(), get active(), get pendingPath(),

  init(siteNavigation),       // construit slidesData depuis les pages listées de Kirby
  setData(path, data),         // stocke les données + loaded=true d'une slide
  setLoading(path, bool),
  slideTo(path),               // anime si chargée, sinon set pendingPath
  resolvePending(),            // appelé quand une slide finit de charger
  getIndexByPath(path),
}

src/router/index.js (réécriture quasi-complète)

Remplace navaid par une logique custom :

// loadSlide(path) :
//   fetch(`${path}.json`) → slides.setData() + site/locale init (1er appel seulement)
//
// loadAllSlidesInBackground(exceptPath) :
//   slides.all.filter(s => s.path !== exceptPath).forEach(s => loadSlide(s.path))
//
// slideTo(path, { skipHistory = false }) :
//   - slide déjà chargée → slides.slideTo() + history.pushState si !skipHistory
//   - slide non chargée → slides.setPending(path) (la nav se déclenche quand loaded)
//
// initRouter() :
//   1. path initial = window.location.pathname (ou '/home' si '/')
//   2. loadSlide(initialPath) :
//      → initialise slides.init(site.navigation) après le 1er fetch
//      → set active slide
//   3. loadAllSlidesInBackground(exceptPath: initialPath)
//   4. popstate listener : slideTo(window.location.pathname, { skipHistory: true })
//   5. Intercepter clics <a> internes : slideTo()

Hack popstate :

window.addEventListener('popstate', () => {
  slideTo(window.location.pathname, { skipHistory: true })
})

Le navigateur change déjà l'URL sur ← → on lit simplement window.location.pathname et on slide sans re-pousser dans l'historique.

Export : initRouter, slideTo


Fichiers à modifier

src/App.svelte

<script>
  import { slides } from '@state/slides.svelte'
  // ... imports views et composants layout
  const templates = { home: Home, expertise: Expertise, ... }
  const width = $derived(`${slides.all.length * 100}vw`)
  const transform = $derived(`translateX(-${slides.activeIndex * 100}vw)`)
</script>

<div class="app">
  <Cursor />
  <Header />
  <main class="main">
    <div class="slides-wrapper" style="width: {width}; transform: {transform}">
      {#each slides.all as slide}
        <div class="slide" data-slide={slide.id}>
          {#if slide.loaded}
            <svelte:component this={templates[slide.template]} data={slide.data} />
          {/if}
        </div>
      {/each}
    </div>
  </main>
</div>

<style>
  :global(body) { overflow: hidden; }
  .main { position: relative; overflow: hidden; height: 100vh; }
  .slides-wrapper {
    display: flex;
    transition: transform 1000ms cubic-bezier(0.77, 0, 0.175, 1);
    height: 100%;
  }
  .slide {
    width: 100vw;
    height: 100vh;
    flex-shrink: 0;
    overflow-y: auto;
  }
</style>

Footer : intégré dans le slide Blog (comme dans le source). Supprimer le Footer global.

src/components/layout/Header.svelte

  • import { slideTo } from '@router' (remplace navigateTo)
  • Appels navigateTo(path)slideTo(path)
  • État actif du lien menu : basé sur slides.active.id

Fichiers inchangés

  • src/views/*.svelte — aucune modification
  • src/state/site.svelte.js
  • src/state/locale.svelte.js
  • src/state/navigation.svelte.js
  • src/main.js
  • src/state/page.svelte.js — peut être supprimé après migration (remplacé par slides store)

Accessibilité (RGAA / WCAG 2.1 AA)

Navigation clavier du système de slides

Le système de slides horizontal est le point le plus délicat : par défaut, un utilisateur au clavier tabulerait dans les slides hors-écran. Il faut :

  • Slides inactives : aria-hidden="true" + tous les éléments focusables à tabindex="-1" → invisibles pour le clavier et les lecteurs d'écran
  • Slide active : aria-hidden="false" + tabindex restauré
  • Navigation au clavier entre slides : touches déclenchent slideTo() (à l'instar des carousels ARIA)
  • Focus management : à chaque changement de slide, déplacer le focus sur le <h1> (ou premier élément focusable) de la nouvelle slide
  • Lien "Aller au contenu" (<a href="#main-content" class="skip-link">) visible au focus, positionné avant le Header

Structure ARIA du conteneur slides

<main id="main-content">
  <div
    role="region"
    aria-label="Navigation entre sections"
    aria-live="polite"
    aria-atomic="false"
  >
    <section aria-label="Accueil" aria-hidden="false"></section>
    <section aria-label="Expertise" aria-hidden="true"></section></div>
</main>

Réduction de mouvement

@media (prefers-reduced-motion: reduce) {
  .slides-wrapper {
    transition: none;
  }
  /* Et toutes les autres transitions/animations */
}

Navigation header

  • <nav aria-label="Navigation principale">
  • aria-current="page" sur le lien de la slide active
  • Le bouton menu (hamburger) : aria-expanded, aria-controls, aria-label

Vidéos

  • muted + autoplay (conforme WCAG si muet)
  • Bouton pause accessible (<button aria-label="Mettre en pause la vidéo">)
  • Pas de contenu informatif uniquement véhiculé par la vidéo (sous-titres si nécessaire)

Contraste et couleurs

  • Vérifier le contraste de #04fea0 sur fond noir : ratio > 4.5:1 (texte normal) ou > 3:1 (grand texte)
  • Ne pas transmettre l'information uniquement par la couleur

Langue

  • lang="fr" sur <html> (géré par Kirby)
  • lang="en" sur les portions en anglais si applicable

Formulaire newsletter (slide Blog)

  • <label> explicite associé au <input type="email">
  • Message d'erreur/succès en aria-live="assertive"

Points de contrôle RGAA prioritaires

Critère Action
Images décoratives alt=""
Images informatives alt descriptif
Liens et boutons Intitulé accessible (pas "cliquez ici")
Ordre de tabulation Logique, suit le DOM visible
Contraste ≥ 4.5:1 texte normal, ≥ 3:1 grand texte
Titre de page <title> mis à jour à chaque navigation
Structure de titres Un seul <h1> par slide, hiérarchie cohérente

Sémantique HTML

Le site source utilise presque exclusivement des <div>. On adopte une sémantique correcte tout en conservant les classes CSS du source pour maintenir le styling.

Élément source (div) Élément cible Rôle
.slides-wrapper div <main> Conteneur principal
Chaque slide <section aria-label="..."> Section de page
Navigation header <nav> Navigation principale
Lien actif aria-current="page" Accessibilité
Contenus vidéo <figure> Média sémantique
Articles de blog <article> Contenu éditorial
Hiérarchie titres <h1> (titre principal unique par slide), <h2>, <h3> Hiérarchie cohérente

Sous-pages : slide vertical imbriqué

Principe : chaque slide principale peut contenir deux sous-vues empilées verticalement. Un clic sur un item déclenche une animation verticale à l'intérieur de la slide, sans quitter le conteneur horizontal.

[Portfolio slide — overflow: hidden]
  [.portfolio-inner — display flex, flex-direction column, transition: transform 500ms]
    [<section> liste — height 100vh]      ← translateY(0) par défaut
    [<section> détail — height 100vh]    ← translateY(100vh) → translateY(0) au clic

Flux de navigation sur clic d'un item :

  1. history.pushState({}, '', '/portfolio/le-point')
  2. Slide horizontal → active le slide Portfolio (si pas déjà actif)
  3. Animation verticale → translate l'inner vers le haut (translateY(-100vh))
  4. Le slide détail monte dans le viewport

Flux sur ← navigateur (popstate) :

  • URL = /portfolio → inverse l'animation verticale, revient à la liste

Avantages :

  • URL maîtrisée, back/forward fonctionnel
  • Cohérent avec le système horizontal (même principe, autre axe)
  • Pas de dépendance extérieure supplémentaire

Slides concernées : Portfolio uniquement (/portfolio/:slug)

Blog (/blog/:slug) : sort complètement du système de slides. Un clic sur un article ouvre la page sans animation de slide — navigation classique qui remplace le viewport. Le retour (← navigateur) revient au slide Blog.


Questions ouvertes (à traiter plus tard)

  • État visuel "en cours de chargement" quand l'utilisateur navigue avant qu'une slide soit prête
  • Footer dans le slide Blog : même structure que le source ?
  • Gestion du scroll vertical dans chaque slide (actuellement overflow-y: auto)

Vérification

  1. Démarrer Kirby (php -S localhost:8000) et Vite (npm run dev)
  2. http://localhost:5173 → Home s'affiche en premier
  3. Console : les 5 autres pages chargent en arrière-plan
  4. Clic sur "Expertise" dans le header → slide horizontal animé 1000ms
  5. Bouton ← du navigateur → retour Home avec animation
  6. Accès direct http://localhost:5173/expertise → slide Expertise active directement
  7. Clic sur un lien avant fin de chargement → attente puis navigation automatique