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

325 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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à :
```json
{
"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 :
```js
// 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 :
```js
// 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 :**
```js
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`
```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
```html
<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
```css
@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