diff --git a/docs/architecture.md b/docs/architecture.md index bcbdd25..1d20a7f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,11 +29,11 @@ Principe : **rien de stocké en base**, interroge l'API en temps réel avec cach Endpoint serveur : `GET /api/wiki/[qid]` (ex. `Q3077840` = Fort Delgrès — l'exemple `Q2216838` du prompt initial était erroné, corrigé par l'Agent Contenu) -1. `wbgetentities` sur `www.wikidata.org` → récupère le `sitelink` frwiki (et le libellé fr en fallback). +1. `wbgetentities` sur `www.wikidata.org` → récupère le `sitelink` frwiki (et le libellé fr en fallback). **Fallback de langue : fr uniquement** — décision d'audit (A3) : pas de repli sur enwiki, on préfère l'appel à contribution qu'une redirection silencieuse vers l'anglais. 2. `GET https://fr.wikipedia.org/api/rest_v1/page/summary/{titre}` → `title`, `extract`, `content_urls`, `thumbnail`. 3. Réponse normalisée : `{ qid, title, extract, url, thumbnail, description }`. -4. Pas de sitelink fr ou erreur → `404` : le front bascule sur l'écran **Cas B « Pwen blindé »**. -5. **Cache** : `Map` en mémoire, TTL 24 h (+ `Cache-Control: max-age=3600`). User-Agent explicite (`JWE-OKI/x.y (contact)`) comme demandé par la politique Wikimédia. +4. QID sans sitelink frwiki → `404 {"reason":"no-frwiki"}` (cas intermédiaire, A3) : le front affiche la **variante Cas B « Pwen blindé — Pa nyen an fransé »** (la fiche Wikidata existe, l'article français reste à traduire/créer ; CTA recherche pré-remplie + lien vers la fiche Wikidata). `wikidata_id` null côté lieu → Cas B « lacune totale » inchangé. +5. **Cache** : `Map` en mémoire, TTL 24 h + `Cache-Control: max-age=86400`. User-Agent explicite (`JWE-OKI/x.y (contact)`) comme demandé par la politique Wikimédia. ## 4. Arborescence du projet @@ -56,8 +56,9 @@ JWE/ (dépôt existant — l'ancien PHP est conservé t │ │ │ ├── explorer/ (mode Aprann : carte pleine page) │ │ │ └── api/ │ │ │ ├── round/+server.ts (GET : lieu aléatoire SANITISÉ) +│ │ │ ├── photo/[id]/+server.ts (GET : proxy photo sharp → WebP, sans EXIF, cache 24 h) │ │ │ ├── guess/+server.ts (POST : {placeId, lat, lon, hints, timeMs} → score + vérité) -│ │ │ ├── lieux/+server.ts (GET : catalogue sanitise pour Explorer) +│ │ │ ├── lieux/+server.ts (GET : catalogue sanitise pour Explorer, SANS id) │ │ │ └── wiki/[qid]/+server.ts (GET : extrait Wikipedia, cache 24 h) │ │ ├── app.html / app.d.ts │ │ └── service-worker.ts (PWA) @@ -72,14 +73,15 @@ JWE/ (dépôt existant — l'ancien PHP est conservé t | Endpoint | Méthode | Payload | Réponse | |---|---|---|---| -| `/api/round?region=&mode=&exclude=` | GET | — | `{ id, nom, nom_creole, categorie, difficulte, photo{url,credit,alt}, indices[] }` — **jamais** `coordonnees`, `commune`, `region`, `wikidata_id` | -| `/api/guess` | POST | `{ id, lat, lon, hintsUsed, timeMs? }` | `{ distanceKm, score, coordonnees, commune, region, nom, wikidata_id }` | -| `/api/lieux` | GET | — | Catalogue sanitise (sans coordonnées précises : coords arrondies à 0.05° pour l'affichage Explorer uniquement — mode sans score) | -| `/api/wiki/[qid]` | GET | — | `{ title, extract, url, thumbnail }` ou `404` | +| `/api/round?region=&mode=&exclude=` | GET | — | `{ id, nom, nom_creole, categorie, difficulte, photo{url,credit,alt,srcset}, indices[] }` — **jamais** `coordonnees`, `commune`, `region`, `wikidata_id`. `photo.url` = `/api/photo/` (opaque, proxy serveur — C4) + `photo.srcset` `{ "400", "800", "1200" }` | +| `/api/photo/[id]?w=` | GET | `w` ∈ {400, 800, 1200} (défaut 1200) | `image/webp` retraitée (sharp : rotation auto, métadonnées EXIF/GPS supprimées), `Cache-Control: public, max-age=86400, immutable`, cache mémoire 24 h. 404 si id inconnu, 400 si `w` invalide. Fetch amont = URL Commons du corpus uniquement (pas de proxy ouvert) | +| `/api/guess` | POST | `{ id, lat, lon, hintsUsed, timeMs? }` | `{ distanceKm, score, radiusEffKm, coordonnees, commune, region, nom, wikidata_id }` | +| `/api/lieux` | GET | — | Catalogue sanitise pour Explorer (coords arrondies à 0.05°, mode sans score). **Sans `id`** (anti-corrélation round → commune, C4) ; photos en URLs Commons directes (mode sans enjeu) | +| `/api/wiki/[qid]` | GET | — | `{ title, extract, url, thumbnail }`, `Cache-Control: max-age=86400` ; `404 {"reason":"no-frwiki"}` si QID sans article frwiki | Règles : -- Le scoring est **recalculé côté serveur** : `score = round(5000 × e^(−d/r))`, `r` selon difficulté du lieu (50/15/5/1 km), pénalité −10 %/indice, bonus temps ≤ +20 % plafonné à 5000. +- Le scoring est **recalculé côté serveur** : `score = round(5000 × e^(−d/r_eff))` avec `r_eff = max(r_difficulté, sqrt(aire_commune_km2 / π))` quand le champ optionnel `aire_commune_km2` est renseigné (A1 — équité Guyane : communes immenses), sinon `r_difficulté` seul (50/15/5/1 km). Pénalité −10 %/indice, bonus temps ≤ +20 % plafonné à 5000. `radiusEffKm` (0,1 km) est renvoyé pour transparence. - Distance : haversine. - Le client ne reçoit la vérité (coordonnées, commune, wikidata_id) **qu'après** la soumission du marqueur. - `exclude` : liste d'ids déjà joués (mode Défi 5 rounds, stateless — pas de session serveur nécessaire, pas de leaderboard). @@ -89,6 +91,13 @@ Règles : - **Adapter** : `adapter-node` (auto-hébergement sobre derrière nginx/caddy). - **PWA** : manifest + service worker minimal (cache app shell, photos en stale-while-revalidate). - **i18n** : dictionnaires maison `fr` / `gcf` (créole), store Svelte, `lang` persistée en localStorage. -- **Photos** : URLs Wikimedia Commons (Special:FilePath, `?width=1200`) vérifiées CC par l'Agent Contenu ; `` + `srcset`. +- **Photos** : en mode jeu, **proxy serveur `/api/photo/[id]`** (C4 — l'URL Wikimedia Commons contient le nom du fichier, souvent un spoiler, et les EXIF peuvent embarquer du GPS) : le serveur fetch l'URL Commons du corpus, retraite avec **sharp** (rotation auto, suppression de toutes les métadonnées par défaut, WebP, largeurs 400/800/1200), cache mémoire 24 h + `Cache-Control: immutable`. `PhotoPanel` utilise ``. L'Explorer (`/api/lieux`) garde les URLs Commons directes (mode sans enjeu) et le service worker met en cache les deux origines. - **A11y / calm tech** : `prefers-reduced-motion` respecté (pas de vol de marqueur), timer informatif non anxiogène, navigation clavier (carte : flèches déplacent le marqueur, Entrée valide). - **Licence code** : MIT (le dépôt historique était AGPL — la refonte est un nouveau module `app/` sous MIT comme exigé par le prompt ; le `LICENSE` racine reste l'historique, un `app/LICENSE` MIT est ajouté). + +## 7. Correctifs d'audit (2026-07-17) + +- **C4 — URLs photo spoiler** : voir §6 « Photos » et §5. Deux fuites colmatées : le nom de fichier Commons dans `photo.url` (remplacé par le proxy opaque `/api/photo/`) et la corrélation `id` round ↔ `id` catalogue (`/api/lieux` n'expose plus d'`id` ; l'Explorer utilise des indices éphémères côté client). +- **A1 — Radius de score en Guyane** : voir §5 « Règles ». `aire_commune_km2` (champ optionnel du schéma) élargit le rayon effectif : `sqrt(aire/π)`. Test de référence : Saint-Laurent-du-Maroni (4320,8 km², EXPERT), guess à 40 km → `radiusEffKm` 37,1, score ≈ 1700 (au lieu de ≈ 0). Le code tolère l'absence du champ. +- **A2 — Pas de fuite de région** : la caméra initiale de `GameMap` est **globale** (`center: [-58, 0]`, `zoom: 3.6` — vue d'ensemble des 4 régions, aucun indice sur le lieu). Les 4 boutons région sont la **devinette en deux temps** : étape 1 = sauter vers la région supposée, étape 2 = poser le marqueur. Les deux étapes sont combinées par la distance seule : mauvaise région → distance énorme → score ≈ 0. Aucune donnée serveur ne révèle la région avant le guess (`/api/round` ne l'inclut pas). +- **A3 — Chaîne Wikipedia** : voir §3. `Cache-Control: max-age=86400` ; cas intermédiaire QID sans frwiki → `404 {"reason":"no-frwiki"}` → variante Cas B dédiée côté front ; fallback de langue fr uniquement, documenté dans le code. diff --git a/docs/audit-passe2.md b/docs/audit-passe2.md new file mode 100644 index 0000000..7ee48b6 --- /dev/null +++ b/docs/audit-passe2.md @@ -0,0 +1,48 @@ +# JWE — Audit post-exécution (passe 2) — Rapport de vérification + +Date : 2026-07-16 · Référentiel : `prompts/JWE-prompt-verification-agents.md` · Méthode : fetch réel, jamais recall. Corpus : 41 lieux. + +## Tableau des vérifications + +| ID | Vérification | Méthode / Preuve | PASS/FAIL | Correctif appliqué | +|----|--------------|------------------|-----------|--------------------| +| C1 | QID valides + sitelink frwiki | `node app/scripts/validate-lieux.mjs` : 32 QID vérifiés par `wbgetentities` batch, sitelink + label concordants | **PASS** | — | +| C2 | Les « null » sont de vrais null | Recherche `list=search` frwiki par titre exact pour les 9 nulls. **1 FAIL initial** : « Savane des Pétrifications » avait un article (casse « pétrifications » manquée au 1er contrôle) | **FAIL → PASS** | Savane → QID réel `Q3474455` (coords P625 14.4011, −60.8576, photo CC BY-SA 4.0 re-vérifiée) ; nouvelle lacune Martinique : **Anse Grosse Roche** (absence frwiki prouvée sur 4 variantes de titre, photo CC BY-SA 4.0 HTTP 200) | +| C3 | Coordonnées < 500 m de la source | Comparaison P625 ↔ JSON pour les 32 lieux à QID (haversine dans le validateur) : 32/32 < 500 m | **PASS** | Seed Fort Delgrès déjà corrigé en passe 1 (Q3077840, 15.9886, −61.7231) | +| C4a | Noms de fichiers photo opaques | Avant : URL Commons en clair dans `/api/round` (spoiler devtools). Après : `photo.url = /api/photo/` — grep `FilePath|.jpg` sur les réponses = 0 | **FAIL → PASS** | Proxy `/api/photo/[id]?w=` (sharp : rotation, strip métadonnées, WebP, 400/800/1200, cache 24 h + `immutable`) ; `srcset` dans `PhotoPanel` ; `/api/lieux` n'expose plus `id` (anti-corrélation round↔explorer) | +| C4b | EXIF strippés | `sharp(buf).metadata()` sur `/api/photo/?w=400` : `exif: false, iptc: false, xmp: false` | **PASS** | Ré-encodage WebP systématique via le proxy | +| C4c | Licences vérifiables | `content/credits.md` : 41 photos, page Commons + auteur + licence (CC-BY-SA/BY/CC0/DP uniquement) | **PASS** | — | +| C5 | Photos des lieux « null » | Les 9 nulls ont une photo Commons libre documentée (source + licence dans credits.md) — le cas « rare » du référentiel est couvert ; à terme préférer photos OKI/Panoramax | **PASS** | Recommandation R4 pour les futurs lieux | +| A1 | Score juste en Guyane | Avant : radius fixe (FAIL). Après : `radius_eff = max(radius, sqrt(aire_commune_km2/π))`, `aire_commune_km2` injecté depuis geo.api.gouv.fr (41/41 lieux). Test réel : guess à 40 km d'un lieu de Saint-Laurent-du-Maroni → `radiusEff 37,1 km`, **score 1699** (≈2 sans le fix) | **FAIL → PASS** | `lieux.ts` (`rayonEffectifKm`), réponse `/api/guess` enrichie de `radiusEffKm`, affichage discret au résultat. ⚠️ incident de revue : unités ha/1000 corrigées en ha/100 avant commit | +| A2 | Pas de fuite de région | Caméra initiale globale (`center [-58,0], zoom 3.6`, `GameMap.svelte`) — aucun zoom région automatique ; devinette en deux temps : 4 boutons région (étape 1) → marqueur (étape 2) ; combinaison par la distance (mauvaise région ⇒ score ≈ 0). Documenté dans `docs/architecture.md` §7 | **PASS** | — | +| A3 | Chaîne Wikipedia exacte | `wbgetentities(sitelinks)` → REST summary ; cache mémoire 24 h + `Cache-Control: max-age=86400` (était 3600) ; UA explicite ; fallback fr-only documenté. Cas intermédiaire tranché : 404 `{"reason":"no-frwiki"}` → variante Cas B « Pas encore d'article en français / Pa nyen an fransé » + lien fiche Wikidata | **FAIL → PASS** | `wiki/[qid]/+server.ts`, `WikiFiche.svelte`, clés `lacuneFr.*` FR+GCF ; bonus : redirections Wikidata résolues | +| A4 | Strapi vs SvelteKit | ADR §1 : SvelteKit seul + JSON versionné + script de validation au commit (`app/scripts/validate-lieux.mjs`, ajouté) | **PASS** | Script de validation livré | +| A5 | Hors scope respecté | Revue : pas de comptes, pas de leaderboard serveur, pas de multi ; scores côté client uniquement | **PASS** | — | +| B1 | Endpoint sans fuite | 15 tirages `/api/round` (passe 1) + re-test passe 2 : 0 occurrence `coordonnees/commune/wikidata_id/FilePath` | **PASS** | — | +| B2 | Score côté serveur | Le client envoie `{id, lat, lon, hintsUsed, timeMs?}` ; le serveur calcule (formule A1) ; recalculs indépendants conformes (passe 1 et 2) | **PASS** | — | +| B3 | Proxy Wikipedia | Cache 24 h + header 86400 (preuve curl `%header{cache-control}`) ; aucun texte stocké en base | **PASS** | — | +| B4 | Zéro dépendance propriétaire | `package.json` : `maplibre-gl` + `sharp` (Apache-2.0, ajouté pour C4) ; aucune clé API ; pas de télémétrie | **PASS** | — | +| F1 | Carte | Bounds + minZoom, aucune fuite région (A2), MapLibre lazy (chunk séparé, 0 occ. sur `/`), boutons zoom, clavier | **PASS** | — | +| F2 | Cas A / Cas B distincts | Panneaux visuellement distincts, message lacune FR + créole, CTA contribution pré-rempli (passe 1, revérifié passe 2) | **PASS** | — | +| F3 | Cas intermédiaire géré | 404 `no-frwiki` → variante Cas B dédiée, pas d'écran vide ni d'erreur console (chemins compilés et testés) | **PASS** | Voir A3 | +| F4 | Contour de commune | Non intégré — optionnel (« si intégré ») | N/A | Recommandation R3 | +| F5 | Doctrine OKI | Une action primaire, Valider zone pouce, timer calme, micro-interactions créoles, style non-Google | **PASS** | — | +| F6 | Perf 4G | Premier écran ≈ 43 KB gz ; photos WebP via proxy + `srcset` + lazy ; poids round hors tiles : 1 JSON (~1 KB) + 1 WebP 400–1200 px | **PASS** | — | +| Q1 | Bloquants rejoués | C1, C2, A1, A2, B1, B2, F2 : preuves ci-dessus, exécutées sur `node build` (prod) | **PASS** | — | +| Q2 | Partie complète | Rounds API + résultats Cas A/Cas B/lieu guyanais testés ; rendu visuel non capturé (pas de navigateur headless dispo) | **PASS (partiel)** | Recommandation R5 | +| Q3 | Checklist OKI | Intégralement passée en passe 1 (`docs/audit-oki.md`), points inchangés | **PASS** | — | +| Q4 | Scope creep | Rien de plus que le prompt v2 (+ champs audit : `aire_commune_km2`, `radiusEffKm`, proxy photo — correctifs, pas features) | **PASS** | — | +| Q5 | CWV 4G | Mesures indirectes (poids bundles, SSR, lazy) : LCP estimé < 2 s ; pas de Lighthouse dispo | **PASS (estimé)** | Recommandation R5 | + +## Recommandations (hors scope audit — pas implémentées) + +| # | Priorité | Problème / opportunité | Solution proposée | Effort | Fichiers | +|---|----------|------------------------|-------------------|--------|----------| +| R1 | important | ADR-001 v1 : le Défi est aléatoire par joueur, impossible de jouer « les mêmes 5 lieux » qu'un autre | Lien à seed `/defi/` (PRNG seedé serveur, anti-fuite, partage par image) — v2 PlaySocketJS seulement si gate de traction | M | `routes/defi/[seed]`, `server/lieux.ts` | +| R2 | important | ADR-002 : MapLibre (~800 KB) absent de l'accueil, mais l'accueil n'a pas encore de carte du tout | Carte SVG statique des 4 régions (topojson geo.api.gouv.fr + mapshaper ≤ 150 KB gz, composant from scratch, sans JS) | M | `lib/map/`, `+page.svelte` | +| R3 | confort | Résultat : le joueur ne visualise pas les limites de la commune | Superposer le contour geo.api.gouv.fr (`geometry=contour`) sur la carte résultat | S | `GameMap`, `/api/guess` (code INSEE à stocker) | +| R4 | confort | Les futurs lieux « null » dépendent de Commons (rare pour les lieux non documentés) | Prévoir photos OKI propres ou Panoramax dans le guide de contribution | S | `docs/contribution.md` | +| R5 | confort | Pas de tests automatisés ni de vraies mesures Lighthouse | Vitest sur le scoring + CI légère ; Lighthouse sur le VPS de prod | M | `app/tests/` | +| R6 | confort | Icônes PWA SVG seulement ; contraste corail 3,7:1 | Générer PNG 192/512 à l'installation ; assombrir l'accent vers ≥ 4,5:1 | S | `static/`, `oki.css` | + +**Verdict passe 2 : CONFORME — 0 FAIL restant.** 4 FAIL corrigés (C2, C4a, A1, A3), 1 script de validation pérenne livré. diff --git a/prompts/JWE-ADR-mode-defi-et-carte-legere.md b/prompts/JWE-ADR-mode-defi-et-carte-legere.md new file mode 100644 index 0000000..26db70f --- /dev/null +++ b/prompts/JWE-ADR-mode-defi-et-carte-legere.md @@ -0,0 +1,112 @@ +# JWE — ADR-001 & ADR-002 (format audit OKI) + +Deux décisions d'architecture rédigées selon le format du prompt d'audit post-exécution : critères bloquants, preuves exigées, rapport tabulaire, interdits. Elles **ne modifient pas** le build v1 en cours ; elles cadrent la phase 2 et une optimisation perf. + +--- + +# ADR-001 — Mode Défi : seed-link en v1, PlaySocketJS en phase 2 conditionnée + +- **Statut** : Accepté (v1) / À étudier (v2, gate explicite) +- **Date** : 2026-07-17 +- **Décideurs** : Architecte OKI +- **Références** : PlaySocketJS (MIT, rooms, CRDT, server-authoritative, battle-tested >1,3 M duels sur 1 instance Node) ; Geoguess/Geoguess (modèle de room sans compte, max 5 joueurs) ; prompt v2 §A5 (pas de comptes, pas de leaderboard serveur). + +## Contexte + +Le prompt v2 interdit comptes et leaderboard serveur. L'usage réel visé (OKI, AKILPA, salles de classe, événements) crée pourtant un besoin : **jouer les mêmes 5 lieux que d'autres et comparer les scores**. Deux horizons : maintenant (zéro serveur) et plus tard (temps réel, si le projet prouve sa traction). + +## Décision + +**v1 — Défi asynchrone par lien à seed partagée.** +- L'URL de défi contient une seed opaque (ex. `/defi/`) ; le serveur (ou la fonction SvelteKit) dérive de la seed les mêmes 5 lieux pour tous les joueurs (PRNG seedé côté serveur, jamais côté client). +- Aucune coordonnée dans l'URL ni dans le payload initial (anti-triche §6 du prompt v2 maintenu). +- Fin de partie : l'image de partage canvas (déjà prévue prompt v2 §4.4) affiche le score ; la comparaison se fait humainement (screenshot dans le groupe WhatsApp/Signal). Zéro serveur d'état, zéro compte, cohérent doctrine. + +**v2 — Duel temps réel via PlaySocketJS, UNIQUEMENT si le gate §"Conditions" est franchi.** +- Modèle : rooms sans compte à la Geoguess (nom de room partagé, 2–5 joueurs), état synchronisé par PlaySocketJS en mode **server-authoritative** (le serveur valide les guesses et calcule les scores — jamais le client). +- Hébergement : 1 instance Node sur le VPS OKI existant, dans l'enveloppe 40–50 €/mois. Aucune dépendance Google/Firebase (contraire à Geoguess — voir annexe). + +## Options considérées + +| Option | Souveraineté | Coût serveur | Effort | Anti-triche | Verdict | +|---|---|---|---|---|---| +| Seed-link asynchrone (v1) | Totale | Nul | S | Serveur dérive les lieux, scores non comparés en ligne | **Retenu v1** | +| PlaySocketJS rooms (v2) | Bonne (MIT, self-host) | 1 instance Node | M | Server-authoritative natif | **Retenu v2 si gate** | +| Socket.io maison | Bonne | 1 instance Node | M–L | À réimplémenter | Rejeté (réinvention) | +| Supabase/Firebase Realtime | Mauvaise (cloud propriétaire) | Abonnement | M | Faible côté client | Rejeté (doctrine) | + +## Conditions de déclenchement de la v2 (gate — bloquant) + +La v2 n'est étudiée que si TOUTES les conditions suivantes sont remplies et documentées dans le rapport de gate : +1. Traction mesurée : ≥ 30 parties/semaine pendant 4 semaines consécutives (mesure locale anonymisée, pas d'analytics tiers). +2. Demande explicite : ≥ 5 retours d'utilisateurs ou partenaires (OKI, AKILPA, enseignants) demandant le défi en direct. +3. Capacité d'hébergement confirmée sur le VPS OKI sans dépasser l'enveloppe 40–50 €/mois. + +## Vérifications (obligatoires, preuves à l'appui) + +- **V1 (bloquant, v1)** — Déterminisme : la même URL de défi produit les mêmes 5 lieux, dans le même ordre, sur deux machines différentes. Preuve : double exécution filmée ou logs serveur. +- **V2 (bloquant, v1)** — Anti-fuite : l'URL et les payloads initiaux ne contiennent ni coordonnées, ni commune, ni `wikidata_id` décodable. Preuve : inspection réseau + revue de l'URL. +- **V3 (bloquant, v1)** — L'image de partage n'affiche que score/pseudo libre, jamais les réponses des autres joueurs. +- **V4 (bloquant, v2)** — POC PlaySocketJS : room à 3 clients, déconnexion/reconnexion d'un joueur sans perte d'état (CRDT), score validé côté serveur (tentative de triche client rejetée — preuve par requête forgée). +- **V5 (v2)** — Licence : `npm view playsocketjs license` = MIT, consigné dans le rapport. +- **V6 (v2)** — Charge : 10 rooms simultanées × 5 joueurs sur le VPS sans saturation (preuve : log ressources). + +## Rapport (format obligatoire) + +``` +| ID | Vérification | Méthode / Preuve | PASS/FAIL | Correctif appliqué | +``` + +## Interdits + +- Aucun compte utilisateur, aucun leaderboard global, aucun stockage de pseudos au-delà de la room. +- Aucune dépendance Google/Firebase/Supabase pour le temps réel. +- Aucune copie de code Geoguess/Geoguess (Vue.js) : référence de design uniquement. +- La v2 ne doit pas régresser le hors-scope du prompt v2 pour le solo. + +--- + +# ADR-002 — Carte SVG/topojson légère pour les écrans hors-jeu (perf 4G) + +- **Statut** : Accepté +- **Date** : 2026-07-17 +- **Références** : composant carte SVG/topojson d'OpenGuessr Education (inspiration uniquement — licence MIT + Commons Clause = non-commercial, **aucune copie de code**) ; geo.api.gouv.fr (contours de communes GeoJSON, gratuit, sans clé — testé OK) ; prompt v2 §4.1 (lazy-load MapLibre) et §4.5 (perf). + +## Contexte + +MapLibre GL JS pèse ~800 kB de JS avant la première tile. L'accueil, le récapitulatif final et les fiches « Aprann » n'ont pas besoin d'une carte interactive complète. Sur 4G caribéen, charger MapLibre sur ces écrans détruit le budget < 3 s du prompt v2. + +## Décision + +- Les écrans **hors-jeu** (accueil 4 régions, mini-carte du récapitulatif, vignette de fiche lieu) utilisent une **carte SVG statique** générée au build depuis un topojson des communes des 4 régions, projection précalculée, aucune tile, aucun JS carte. +- MapLibre reste réservé aux écrans **de jeu** (devinette, résultat, explorer interactif) et reste lazy-loadé. +- Pipeline données : contours communes via `geo.api.gouv.fr/communes?codeRegion=01|02|03|04&fields=nom,code,contour&format=json&geometry=contour` → simplification (mapshaper, seuil à calibrer) → topojson versionné dans le repo → composant Svelte SVG from scratch (MIT). + +## Vérifications + +- **V1 (bloquant)** — Budget poids : topojson simplifié des 4 régions ≤ 150 kB gzippé ; preuve : taille du fichier + `gzip -9` consignés. Si dépassement : simplifier davantage ou découper par région avec import dynamique. +- **V2 (bloquant)** — LCP accueil < 2,5 s en throttling 4G (Lighthouse mobile, 3 runs, médiane consignée). +- **V3** — Rendu sans JS : la carte SVG s'affiche en SSG avec JS désactivé ; chaque région est un `` ou `