diff --git a/.gitignore b/.gitignore
index 6d2de38..594cffa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,6 @@
# Paquet YunoHost (dépôt séparé : ORGANISATION-KA-INTERNATIONALE/jwe_ynh)
jwe_ynh/
+
+# Artefacts de debug Kimi CLI
+kimi-debug-session*.zip
+
diff --git a/app/src/app.html b/app/src/app.html
index cc0701d..34ef314 100644
--- a/app/src/app.html
+++ b/app/src/app.html
@@ -4,6 +4,11 @@
+
%sveltekit.head%
diff --git a/app/src/hooks.server.ts b/app/src/hooks.server.ts
new file mode 100644
index 0000000..08282dc
--- /dev/null
+++ b/app/src/hooks.server.ts
@@ -0,0 +1,43 @@
+import type { Handle } from '@sveltejs/kit';
+
+/**
+ * CSP — domaines justifiés :
+ * - tiles.openfreemap.org : style MapLibre (tuiles vectorielles + raster
+ * natural_earth, sprites, glyphs — cf. src/lib/map/style-oki.json) ;
+ * - upload.wikimedia.org : vignettes des fiches Wikipédia (wiki.thumbnail,
+ * affichée en
) et photos Commons (service worker, Special:FilePath) ;
+ * - commons.wikimedia.org : variantes Special:FilePath (service worker) ;
+ * - graph.mapillary.com : API Mapillary (mapillary-js, seul domaine en dur
+ * dans la lib) ; tiles.mapillary.com : tuiles vectorielles de couverture ;
+ * images.mapillary.com : CDN des photos 360° (fetch ArrayBuffer → blob:) ;
+ * - www.wikidata.org / fr.wikipedia.org : appelées côté serveur uniquement
+ * (proxy /api/wiki), listées ici par précaution si un appel direct apparaît ;
+ * - 'unsafe-inline' sur script-src : script inline de app.html (gate motion)
+ * + payload d'hydratation injecté par SvelteKit adapter-node ;
+ * - worker-src blob: : workers WebGL de MapLibre chargés en blob.
+ */
+const CSP = [
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://tiles.openfreemap.org",
+ "font-src 'self' data: https://tiles.openfreemap.org",
+ "connect-src 'self' https://tiles.openfreemap.org https://graph.mapillary.com https://tiles.mapillary.com https://images.mapillary.com https://www.wikidata.org https://fr.wikipedia.org https://upload.wikimedia.org https://commons.wikimedia.org",
+ "worker-src 'self' blob:",
+ "frame-ancestors 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "object-src 'none'"
+].join('; ');
+
+// Le jeu n'utilise ni caméra, ni micro, ni géolocalisation (la position est
+// devinée au clic sur la carte) — Permissions-Policy les coupe explicitement.
+export const handle: Handle = async ({ event, resolve }) => {
+ const response = await resolve(event);
+ response.headers.set('Content-Security-Policy', CSP);
+ response.headers.set('X-Content-Type-Options', 'nosniff');
+ response.headers.set('X-Frame-Options', 'DENY');
+ response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+ response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
+ return response;
+};
diff --git a/app/src/lib/components/CarteRegions.svelte b/app/src/lib/components/CarteRegions.svelte
index 45e9544..e0afcb0 100644
--- a/app/src/lib/components/CarteRegions.svelte
+++ b/app/src/lib/components/CarteRegions.svelte
@@ -125,6 +125,8 @@
+
+
+ {titreComplet}
+
+
+
+
+
+
+
+
+
+ {#if image}
+
+ {/if}
+
+
+
+
+ {#if image}
+
+ {/if}
+
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index c6aef7f..9669811 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -56,8 +56,6 @@ const fr = {
}
},
resultat: {
- bravo: 'Bravo !',
- score: 'Score',
points: 'points',
distance: 'Distance',
km: 'km',
@@ -94,7 +92,6 @@ const fr = {
telecharger: 'Télécharger l’image',
taPosition: 'Ta position',
positionReelle: 'Position réelle',
- manche: 'Manche',
titreSeed: 'Défi — seed',
copierLien: 'Copier le lien du défi',
lienCopie: 'Lien copié !'
@@ -115,8 +112,7 @@ const fr = {
},
micro: {
bravo: 'Bravo !',
- kiteSaYe: 'Kité sa ye ?',
- pwenBlinde: 'Pwen blindé'
+ kiteSaYe: 'Kité sa ye ?'
},
langue: {
fr: 'Français',
diff --git a/app/src/lib/i18n/gcf.ts b/app/src/lib/i18n/gcf.ts
index dfcabdd..2808bc6 100644
--- a/app/src/lib/i18n/gcf.ts
+++ b/app/src/lib/i18n/gcf.ts
@@ -58,8 +58,6 @@ const gcf: Dict = {
}
},
resultat: {
- bravo: 'Bravo !',
- score: 'Pwen',
points: 'pwen',
distance: 'Distans',
km: 'km',
@@ -96,7 +94,6 @@ const gcf: Dict = {
telecharger: 'Téléchajé foto-a',
taPosition: 'Pozisyon ou',
positionReelle: 'Rèl pozisyon-an',
- manche: 'Mannèk',
titreSeed: 'Défi — seed',
copierLien: 'Kopié lyen défi-a',
lienCopie: 'Lyen kopié !'
@@ -117,8 +114,7 @@ const gcf: Dict = {
},
micro: {
bravo: 'Bravo !',
- kiteSaYe: 'Kité sa ye ?',
- pwenBlinde: 'Pwen blindé'
+ kiteSaYe: 'Kité sa ye ?'
},
langue: {
fr: 'Français',
diff --git a/app/src/lib/motion/countup.ts b/app/src/lib/motion/countup.ts
new file mode 100644
index 0000000..6c0ed96
--- /dev/null
+++ b/app/src/lib/motion/countup.ts
@@ -0,0 +1,56 @@
+import { dureeMs, easeSyncope, prefersReducedMotion } from './tokens';
+
+/**
+ * Action `use:compte={cible}` — count-up de 0 (ou de la valeur précédente)
+ * vers la cible, en `--dur-compte` avec easing syncopé. L'écriture se fait
+ * directement dans le DOM via rAF : jamais d'état réactif par frame.
+ *
+ * Reduced-motion : la valeur finale est écrite immédiatement. Le markup doit
+ * contenir la valeur finale (état SSR / premier rendu) et être doublé d'un
+ * équivalent non animé pour les lecteurs d'écran (le span animé porte
+ * aria-hidden="true" chez l'appelant).
+ */
+export function compte(node: HTMLElement, cible: number) {
+ let raf: number | null = null;
+ let affiche = 0;
+
+ function ecrire(valeur: number): void {
+ affiche = valeur;
+ node.textContent = String(valeur);
+ }
+
+ function jouer(vers: number): void {
+ if (raf !== null) {
+ cancelAnimationFrame(raf);
+ raf = null;
+ }
+ const depuis = affiche;
+ const duree = dureeMs('--dur-compte');
+ if (prefersReducedMotion() || vers === depuis || duree <= 0) {
+ ecrire(vers);
+ return;
+ }
+ const t0 = performance.now();
+ const tick = (t: number): void => {
+ const p = Math.min(1, (t - t0) / duree);
+ ecrire(Math.round(depuis + (vers - depuis) * easeSyncope(p)));
+ if (p < 1) {
+ raf = requestAnimationFrame(tick);
+ } else {
+ raf = null;
+ }
+ };
+ raf = requestAnimationFrame(tick);
+ }
+
+ jouer(cible);
+
+ return {
+ update(nouvelleCible: number) {
+ jouer(nouvelleCible);
+ },
+ destroy() {
+ if (raf !== null) cancelAnimationFrame(raf);
+ }
+ };
+}
diff --git a/app/src/lib/motion/reveal.ts b/app/src/lib/motion/reveal.ts
new file mode 100644
index 0000000..a629f2f
--- /dev/null
+++ b/app/src/lib/motion/reveal.ts
@@ -0,0 +1,44 @@
+/**
+ * Action `use:reveal` — ajoute la classe `in` à l'entrée dans le viewport.
+ *
+ * Variante SSR de la brique o-k-i.net : l'état caché initial est rendu côté
+ * serveur (`data-reveal` + `--d` directement dans le markup), et n'est actif
+ * que sous `html.js` (classe posée par le script de app.html) ET
+ * `prefers-reduced-motion: no-preference` (media query dans oki.css). Sans JS
+ * ou en reduced-motion, le contenu est intégralement visible.
+ *
+ * @param actif false = pas d'observation (ex. CarteRegions sans cascade)
+ */
+export function reveal(node: Element, actif: boolean = true) {
+ let observer: IntersectionObserver | null = null;
+
+ function observerNoeud(actifCourant: boolean): void {
+ observer?.disconnect();
+ observer = null;
+ if (!actifCourant) return;
+ observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ node.classList.add('in');
+ observer?.disconnect();
+ observer = null;
+ }
+ }
+ },
+ { threshold: 0.15, rootMargin: '0px 0px -5% 0px' }
+ );
+ observer.observe(node);
+ }
+
+ observerNoeud(actif);
+
+ return {
+ update(nouvelActif: boolean) {
+ observerNoeud(nouvelActif);
+ },
+ destroy() {
+ observer?.disconnect();
+ }
+ };
+}
diff --git a/app/src/lib/motion/tokens.ts b/app/src/lib/motion/tokens.ts
new file mode 100644
index 0000000..7d39ed6
--- /dev/null
+++ b/app/src/lib/motion/tokens.ts
@@ -0,0 +1,42 @@
+import { browser } from '$app/environment';
+
+/** Gate unique prefers-reduced-motion (playbook §6). */
+export function prefersReducedMotion(): boolean {
+ return browser && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
+
+/**
+ * Stagger syncopé de marque OKI (3+3+2) — charte §3.
+ * Délais en ms pour l'index donné (cycle de 8 éléments).
+ */
+const SYNCOPATED_DELAYS = [0, 120, 300, 360, 600, 660, 900, 960];
+
+export function syncopatedDelay(index: number): number {
+ const cycle = Math.floor(index / 8);
+ return SYNCOPATED_DELAYS[index % 8] + cycle * 1200;
+}
+
+/**
+ * Lit une durée depuis les tokens CSS de oki.css (--dur-*) — les valeurs ne
+ * sont jamais dupliquées côté appelant. Client uniquement (les transitions
+ * Svelte et les rAF ne courent de toute façon pas côté serveur).
+ * Retourne 0 si le token est absent : l'animation se replie alors en
+ * application immédiate, sans valeur de secours hardcodée.
+ */
+export function dureeMs(nom: string): number {
+ if (!browser) return 0;
+ const brut = getComputedStyle(document.documentElement).getPropertyValue(nom).trim();
+ if (brut.endsWith('ms')) return Number.parseFloat(brut) || 0;
+ if (brut.endsWith('s')) return (Number.parseFloat(brut) || 0) * 1000;
+ return 0;
+}
+
+/** cubic-bezier(0.22, 1, 0.36, 1) — --ease-ka : attaque franche, longue tenue. */
+export function easeKa(x: number): number {
+ return 1 - Math.pow(1 - x, 5);
+}
+
+/** cubic-bezier(0.65, 0, 0.35, 1) — --ease-syncope : symétrique, urgent. */
+export function easeSyncope(x: number): number {
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
+}
diff --git a/app/src/lib/motion/transitions.ts b/app/src/lib/motion/transitions.ts
new file mode 100644
index 0000000..80e872c
--- /dev/null
+++ b/app/src/lib/motion/transitions.ts
@@ -0,0 +1,20 @@
+import { fade, fly, type FadeParams, type FlyParams } from 'svelte/transition';
+import { dureeMs, prefersReducedMotion } from './tokens';
+
+/**
+ * Transitions d'état du jeu — enrobent les transitions natives Svelte avec :
+ * 1. des durées lues depuis les tokens CSS (--dur-*, jamais dupliquées) ;
+ * 2. la gate prefers-reduced-motion (durée 0 → état final immédiat).
+ */
+
+/** Fondu simple à la durée `--dur-mesure`. */
+export function fonduMesure(node: Element, params: FadeParams = {}) {
+ if (prefersReducedMotion()) return fade(node, { ...params, duration: 0 });
+ return fade(node, { duration: dureeMs('--dur-mesure'), ...params });
+}
+
+/** Montée courte (translateY + fondu) à la durée `--dur-mesure`. */
+export function monteeMesure(node: Element, params: FlyParams = {}) {
+ if (prefersReducedMotion()) return fly(node, { ...params, duration: 0, y: 0 });
+ return fly(node, { duration: dureeMs('--dur-mesure'), y: 14, ...params });
+}
diff --git a/app/src/lib/partage.ts b/app/src/lib/partage.ts
new file mode 100644
index 0000000..c0043e5
--- /dev/null
+++ b/app/src/lib/partage.ts
@@ -0,0 +1,61 @@
+import { t } from '$lib/i18n/store.svelte';
+import { defi } from '$lib/stores/defi.svelte';
+
+/** Génère une image canvas téléchargeable : score total + manches du défi. */
+export function partager(): void {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1080;
+ canvas.height = 1080;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ // Fond sable + bandeau vert profond
+ ctx.fillStyle = '#f5efe2';
+ ctx.fillRect(0, 0, 1080, 1080);
+ ctx.fillStyle = '#0b3d2e';
+ ctx.fillRect(0, 0, 1080, 240);
+
+ ctx.fillStyle = '#fffdf8';
+ ctx.textAlign = 'center';
+ ctx.font = '800 96px system-ui, sans-serif';
+ ctx.fillText('JWE', 540, 130);
+ ctx.font = '600 44px system-ui, sans-serif';
+ ctx.fillText(t().modes.defi, 540, 200);
+
+ // Score total
+ ctx.fillStyle = '#0b3d2e';
+ ctx.font = '800 150px system-ui, sans-serif';
+ ctx.fillText(`${defi.total}`, 540, 420);
+ ctx.fillStyle = '#5a6b62';
+ ctx.font = '500 40px system-ui, sans-serif';
+ ctx.fillText(t().defi.scoreTotal, 540, 480);
+
+ // Manches : lieu, région, score
+ let y = 580;
+ for (const [i, m] of defi.manches.entries()) {
+ ctx.fillStyle = i % 2 === 0 ? '#e3efe7' : '#f5efe2';
+ ctx.fillRect(90, y - 44, 900, 74);
+ ctx.fillStyle = '#22302a';
+ ctx.textAlign = 'left';
+ ctx.font = '600 34px system-ui, sans-serif';
+ ctx.fillText(`${i + 1}. ${m.result.nom}`, 120, y);
+ ctx.fillStyle = '#17624a';
+ ctx.font = '500 30px system-ui, sans-serif';
+ ctx.fillText(t().regions[m.result.region], 120, y + 34);
+ ctx.textAlign = 'right';
+ ctx.fillStyle = '#0b3d2e';
+ ctx.font = '800 40px system-ui, sans-serif';
+ ctx.fillText(`${m.result.score}`, 960, y + 8);
+ y += 92;
+ }
+
+ ctx.fillStyle = '#5a6b62';
+ ctx.textAlign = 'center';
+ ctx.font = '500 30px system-ui, sans-serif';
+ ctx.fillText('labola.o-k-i.net', 540, 1010);
+
+ const a = document.createElement('a');
+ a.href = canvas.toDataURL('image/png');
+ a.download = 'jwe-defi.png';
+ a.click();
+}
diff --git a/app/src/lib/site.ts b/app/src/lib/site.ts
new file mode 100644
index 0000000..893bf34
--- /dev/null
+++ b/app/src/lib/site.ts
@@ -0,0 +1,3 @@
+/** Constantes du site — domaine public de production (cf. jwe_ynh/manifest.toml). */
+export const SITE_URL = 'https://jwe.o-k-i.net';
+export const SITE_NAME = 'JWE';
diff --git a/app/src/lib/styles/jeu.css b/app/src/lib/styles/jeu.css
new file mode 100644
index 0000000..6e2a854
--- /dev/null
+++ b/app/src/lib/styles/jeu.css
@@ -0,0 +1,514 @@
+/* Styles partagés des écrans de jeu : /jeu/[mode] et /defi/[seed].
+ Layout « app » verrouillé au viewport (100dvh via +layout) : la page ne
+ scrolle jamais, seuls les panneaux internes défilent. Mobile-first. */
+
+.jeu {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.etat {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ padding: 1rem;
+ overflow-y: auto;
+ color: var(--oki-encre-doux);
+}
+
+/* — Split photo | carte — */
+.split {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ min-height: 0;
+}
+
+/* Mobile : la photo s'adapte (plafond ~42dvh), la carte garde au minimum
+ 35dvh utiles + la barre de sauts (~3,5rem). */
+.panneau-photo {
+ position: relative; /* ancre du panneau d'indices */
+ flex: 1 1 0;
+ min-height: 5rem;
+ max-height: 42dvh;
+}
+
+.panneau-carte {
+ position: relative; /* ancre de la puce de statut et de l'aide */
+ flex: 1 1 0;
+ min-height: calc(35dvh + 3.5rem);
+ display: flex;
+}
+
+.panneau-carte > * {
+ flex: 1;
+ min-width: 0;
+}
+
+/* État résultat : le panneau photo devient une fiche scrollable interne. */
+.split.resultat .panneau-photo {
+ max-height: none;
+}
+
+.split.resultat .panneau-carte {
+ min-height: 10rem;
+}
+
+.resultat-scroll {
+ height: 100%;
+ overflow-y: auto;
+ background: var(--oki-blanc);
+ border-radius: var(--oki-radius);
+ box-shadow: var(--oki-ombre);
+ padding: 1rem;
+}
+
+/* — Puce de statut unique (manche, seed, minuteur) : haut-droite, une ligne.
+ Aucun élément absolu au-dessus des zones interactives : les badges sont
+ décoratifs, les clics les traversent. — */
+.statut {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.4rem;
+ max-width: calc(100% - 1rem);
+ pointer-events: none;
+}
+
+.badge-manche {
+ background: rgba(255, 253, 248, 0.94);
+ color: var(--oki-vert);
+ border-radius: 999px;
+ padding: 0.25rem 0.8rem;
+ font-size: 0.82rem;
+ font-weight: 700;
+ white-space: nowrap;
+ box-shadow: var(--oki-ombre);
+}
+
+/* Seed visible mais discrète : les joueurs vérifient qu'ils jouent le même défi */
+.badge-seed {
+ background: rgba(255, 253, 248, 0.8);
+ color: var(--oki-encre-doux);
+ border-radius: 999px;
+ padding: 0.15rem 0.7rem;
+ font-size: 0.72rem;
+ font-weight: 600;
+ white-space: nowrap;
+ box-shadow: var(--oki-ombre);
+}
+
+.badge-timer {
+ display: flex;
+}
+
+/* — Indices : panneau scrollable interne, en surimpression de la photo
+ (zone non interactive) — jamais au-dessus de la carte. — */
+.indices {
+ position: absolute;
+ left: 0.4rem;
+ right: 0.4rem;
+ bottom: 0.4rem;
+ z-index: 4;
+ max-height: calc(100% - 0.8rem);
+ overflow-y: auto;
+ margin: 0;
+ padding: 0.4rem 0.6rem;
+ background: rgba(255, 253, 248, 0.96);
+ border-radius: var(--oki-radius-petit);
+ box-shadow: var(--oki-ombre);
+}
+
+.indice {
+ margin: 0.25rem 0;
+ background: var(--oki-bleu-doux);
+ border-left: 4px solid var(--oki-bleu);
+ border-radius: var(--oki-radius-petit);
+ padding: 0.5rem 0.8rem;
+ font-size: 0.95rem;
+}
+
+/* — Aide « poser le marqueur » : pastille décorative sur la carte, les clics
+ la traversent (pointer-events: none). Sur mobile elle flotte au bas du
+ canevas, juste au-dessus de la barre de sauts (~3,65rem) ; sur desktop elle
+ est décalée à droite pour ne pas chevaucher les sauts en overlay bas-gauche. — */
+.aide-carte {
+ position: absolute;
+ left: 50%;
+ bottom: 3.9rem; /* 48px de sauts + espacements, arrondi au-dessus */
+ transform: translateX(-50%);
+ z-index: 3;
+ margin: 0;
+ padding: 0.3rem 0.9rem;
+ max-width: calc(100% - 5rem);
+ background: rgba(255, 253, 248, 0.92);
+ color: var(--oki-encre-doux);
+ border-radius: 999px;
+ box-shadow: var(--oki-ombre);
+ font-size: 0.85rem;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+}
+
+/* — Barre d'actions : en bas du flux (la page ne scrolle pas), opaque,
+ au-dessus de la carte grâce au z-index explicite. — */
+.barre-actions {
+ position: sticky;
+ bottom: 0;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.6rem;
+ padding: 0.6rem 0.8rem calc(0.6rem + env(safe-area-inset-bottom));
+ background: var(--oki-blanc);
+ border-top: 1px solid var(--oki-sable-fonce);
+}
+
+.barre-actions .btn {
+ min-height: 48px; /* cible tactile */
+}
+
+.actions-secondaires {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.actions-secondaires .btn {
+ padding: 0.6rem 0.9rem;
+ font-size: 0.92rem;
+}
+
+.penalite {
+ font-size: 0.75rem;
+ font-weight: 500;
+ opacity: 0.8;
+}
+
+.valider {
+ flex: 0 1 auto;
+ padding: 0.8rem 1.2rem;
+ white-space: nowrap;
+}
+
+/* Écrans très étroits : tout doit tenir sur une ligne. */
+@media (max-width: 420px) {
+ .actions-secondaires .btn {
+ padding: 0.55rem 0.7rem;
+ font-size: 0.86rem;
+ }
+
+ .penalite {
+ display: none;
+ }
+
+ .valider {
+ padding: 0.7rem 0.9rem;
+ font-size: 0.95rem;
+ }
+}
+
+/* Paysage / écrans très bas : on relâche les planchers pour éviter le rognage. */
+@media (max-height: 540px) {
+ .panneau-photo {
+ min-height: 3.5rem;
+ max-height: 34dvh;
+ }
+
+ .panneau-carte {
+ min-height: 11rem;
+ }
+}
+
+/* Desktop : split 50/50 qui remplit exactement la hauteur disponible. */
+@media (min-width: 768px) {
+ .split {
+ flex-direction: row;
+ gap: 0.8rem;
+ padding: 0.8rem;
+ }
+
+ .panneau-photo,
+ .split.resultat .panneau-photo {
+ flex: 1 1 50%;
+ min-height: 0;
+ max-height: none;
+ }
+
+ .panneau-carte,
+ .split.resultat .panneau-carte {
+ flex: 1 1 50%;
+ min-height: 0;
+ }
+
+ .aide-carte {
+ left: auto;
+ right: 0.8rem;
+ bottom: 3.4rem; /* au-dessus de la rangée de sauts en overlay bas-gauche */
+ transform: none;
+ }
+
+ .barre-actions {
+ justify-content: center;
+ gap: 1.5rem;
+ }
+}
+
+/* — Écran bilan du défi : compact, scroll interne en dernier recours. — */
+.bilan {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
+.bilan h1 {
+ margin: 0;
+ text-align: center;
+ font-size: 1.6rem;
+ color: var(--oki-vert);
+}
+
+/* Filet drapeau du bilan : 3 segments francs aux couleurs OKI qui se
+ dessinent (scaleX) en cascade syncopée — sobre, pas de confettis. */
+.filet-drapeau {
+ display: flex;
+ width: 132px;
+ height: 5px;
+ margin: 0 auto;
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.filet-drapeau .seg {
+ flex: 1;
+ transform-origin: left center;
+}
+
+.filet-drapeau .seg-vert {
+ background: var(--oki-vert);
+}
+
+.filet-drapeau .seg-corail {
+ background: var(--oki-corail);
+}
+
+.filet-drapeau .seg-bleu {
+ background: var(--oki-bleu);
+}
+
+/* Seed rappelée sous le titre du bilan (page /defi/[seed]) */
+.seed-discret {
+ margin: 0;
+ text-align: center;
+ font-size: 0.9rem;
+ color: var(--oki-encre-doux);
+}
+
+.total {
+ margin: 0;
+ text-align: center;
+}
+
+.total .valeur {
+ font-size: 2.4rem;
+ font-weight: 800;
+ color: var(--oki-vert);
+}
+
+.total .unite {
+ margin-left: 0.4rem;
+ color: var(--oki-encre-doux);
+}
+
+/* Carte SVG du récap : hauteur plafonnée à 44dvh via la largeur
+ (viewBox 600×620 → hauteur ≈ largeur × 1,033). */
+.bilan-carte {
+ width: min(100%, 42.5dvh, 520px);
+ margin: 0 auto;
+}
+
+.recap {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.recap li {
+ display: flex;
+ align-items: baseline;
+ gap: 0.6rem;
+ background: var(--oki-blanc);
+ border-radius: var(--oki-radius-petit);
+ box-shadow: var(--oki-ombre);
+ padding: 0.5rem 0.9rem;
+}
+
+.r-nom {
+ font-weight: 700;
+ color: var(--oki-vert);
+}
+
+.r-region {
+ flex: 1;
+ color: var(--oki-encre-doux);
+ font-size: 0.9rem;
+}
+
+.r-score {
+ font-weight: 800;
+}
+
+.bilan-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.7rem;
+}
+
+.bilan-actions .btn {
+ min-height: 48px;
+}
+
+.legende {
+ margin: 0;
+ text-align: center;
+ font-size: 0.85rem;
+ color: var(--oki-encre-doux);
+}
+
+.puce.joueur {
+ color: var(--oki-corail);
+}
+
+.puce.reelle {
+ color: var(--oki-vert);
+}
+
+/* Bilan desktop : deux colonnes (carte | récap) pour tenir sans scroll. */
+@media (min-width: 900px) {
+ .bilan {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ align-content: start;
+ column-gap: 1.5rem;
+ }
+
+ .bilan h1,
+ .bilan .seed-discret,
+ .bilan .filet-drapeau,
+ .bilan .total,
+ .bilan .bilan-actions,
+ .bilan .legende {
+ grid-column: 1 / -1;
+ }
+
+ .bilan-carte {
+ width: min(100%, 42.5dvh);
+ justify-self: center;
+ }
+}
+
+/* Hauteur limitée : resserre le bilan pour rester sans scroll dès 800px. */
+@media (max-height: 800px) {
+ .bilan {
+ gap: 0.55rem;
+ padding-top: 0.6rem;
+ }
+
+ .bilan h1 {
+ font-size: 1.35rem;
+ }
+
+ .total .valeur {
+ font-size: 1.9rem;
+ }
+}
+
+/* — Motion (playbook §1.7) : transform/opacity uniquement, tout est sous
+ gate html.js + no-preference — contenu statique complet sans JS ni motion. — */
+@media (prefers-reduced-motion: no-preference) {
+ /* Moment révélation : la fiche résultat monte pendant que la carte prend
+ l'emphase (léger zoom de reprise, --dur-mesure --ease-ka). */
+ html.js .resultat-scroll {
+ animation: jwe-montee var(--dur-mesure) var(--ease-ka);
+ }
+
+ html.js .split.resultat .panneau-carte {
+ animation: jwe-emphase var(--dur-mesure) var(--ease-ka);
+ }
+
+ /* Pastilles de statut (manche, seed, minuteur) : apparition en pop. */
+ html.js .badge-manche,
+ html.js .badge-seed,
+ html.js .badge-timer {
+ animation: oki-pop var(--dur-mesure) var(--ease-ka) backwards;
+ }
+
+ /* Filet drapeau du bilan : segments qui se dessinent 0 / 120 / 300 ms. */
+ html.js .filet-drapeau .seg {
+ animation: jwe-filet var(--dur-mesure) var(--ease-ka) backwards;
+ }
+
+ html.js .filet-drapeau .seg-corail {
+ animation-delay: 120ms;
+ }
+
+ html.js .filet-drapeau .seg-bleu {
+ animation-delay: 300ms;
+ }
+}
+
+@keyframes jwe-montee {
+ from {
+ transform: translateY(18px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes jwe-emphase {
+ from {
+ transform: scale(0.965);
+ opacity: 0.7;
+ }
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@keyframes jwe-filet {
+ from {
+ transform: scaleX(0);
+ }
+ to {
+ transform: scaleX(1);
+ }
+}
diff --git a/app/src/lib/styles/oki.css b/app/src/lib/styles/oki.css
index e09ca29..48d73b2 100644
--- a/app/src/lib/styles/oki.css
+++ b/app/src/lib/styles/oki.css
@@ -25,6 +25,61 @@
'Segoe UI',
Roboto,
sans-serif;
+
+ /* Cadences gwoka (playbook §1.7) — lues aussi depuis le JS via
+ getComputedStyle ($lib/motion/tokens.ts : dureeMs). */
+ --ease-ka: cubic-bezier(0.22, 1, 0.36, 1); /* temps fort : attaque franche, longue tenue */
+ --ease-syncope: cubic-bezier(0.65, 0, 0.35, 1); /* contretemps : symétrique, urgent */
+ --dur-tanbou: 120ms; /* double-croche — micro-interactions, hovers */
+ --dur-mesure: 480ms; /* une mesure — entrées, transitions d'état */
+ --dur-phrase: 960ms; /* deux mesures — moments solennels (ligne de distance) */
+ --dur-compte: 800ms; /* count-up du score — entre mesure et phrase */
+}
+
+/* Lecture d'écran : valeur finale non animée qui double un compteur animé
+ (le span animé porte aria-hidden="true"). */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* Pop de marque (pastilles, marqueurs explorer) : scale 0.6 → 1.
+ Toujours appliqué sous gate html.js + no-preference chez l'appelant. */
+@keyframes oki-pop {
+ from {
+ transform: scale(0.6);
+ opacity: 0;
+ }
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+/* États initiaux des reveals : uniquement si JS actif (classe `js` posée par
+ app.html) ET motion autorisée — sans JS ou en reduced-motion, tout le
+ contenu est visible (playbook §6). --d = délai syncopé posé dans le markup. */
+@media (prefers-reduced-motion: no-preference) {
+ html.js [data-reveal] {
+ opacity: 0;
+ transform: translateY(28px);
+ transition:
+ opacity var(--dur-mesure) var(--ease-ka) var(--d, 0ms),
+ transform var(--dur-mesure) var(--ease-ka) var(--d, 0ms);
+ }
+
+ html.js [data-reveal].in {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
* {
@@ -50,6 +105,9 @@ button {
/* — Boutons — */
.btn {
+ position: relative;
+ isolation: isolate; /* le balayage ::after reste sous le texte */
+ overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -61,7 +119,11 @@ button {
font-weight: 650;
cursor: pointer;
text-decoration: none;
- transition: background-color 0.15s ease;
+ transition:
+ background-color var(--dur-tanbou) var(--ease-ka),
+ color var(--dur-tanbou) var(--ease-ka),
+ border-color var(--dur-tanbou) var(--ease-ka),
+ transform var(--dur-tanbou) var(--ease-ka);
}
.btn:focus-visible,
@@ -78,8 +140,25 @@ a:focus-visible,
box-shadow: var(--oki-ombre);
}
-.btn-primaire:hover {
+/* Balayage corail foncé qui glisse depuis la gauche (hover craft, pattern
+ .btn OKI adapté au DS corail/vert — transform only) */
+.btn-primaire::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: -1;
background: var(--oki-corail-fonce);
+ transform: scaleX(0);
+ transform-origin: left center;
+ transition: transform var(--dur-tanbou) var(--ease-syncope);
+}
+
+.btn-primaire:not(:disabled):hover {
+ transform: translateY(-2px);
+}
+
+.btn-primaire:not(:disabled):hover::after {
+ transform: scaleX(1);
}
.btn-primaire:disabled {
diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte
index 6a2118f..f19a19c 100644
--- a/app/src/routes/+layout.svelte
+++ b/app/src/routes/+layout.svelte
@@ -32,7 +32,7 @@
diff --git a/app/src/routes/defi/[seed]/+page.svelte b/app/src/routes/defi/[seed]/+page.svelte
index 6e3c1e9..5ff20a6 100644
--- a/app/src/routes/defi/[seed]/+page.svelte
+++ b/app/src/routes/defi/[seed]/+page.svelte
@@ -1,11 +1,18 @@
-
- {t().app.titre} — {t().modes.defi} — seed {seed}
-
+
{#if bilan}
-
+
{t().defi.bilan}
{t().defi.titreSeed} {seed}
+
+
+
- {defi.total}
+ {defi.total}
+ {defi.total}
{t().defi.scoreTotal}
@@ -214,7 +168,7 @@
{#each defi.manches as m, i (m.place.id)}
- -
+
-
{i + 1}. {m.result.nom}
{t().regions[m.result.region]}
{m.result.score} {t().resultat.points}
@@ -239,14 +193,14 @@
{:else if erreur}
-
+
{t().jeu.erreur}
{:else if place}
-
+
{#if result}
@@ -307,284 +273,9 @@
{t().jeu.valider}
- {#if indicesMontres.length > 0}
-
- {/if}
- {#if !guess}
-
{t().jeu.poserMarqueur}
- {/if}
{/if}
{:else}
-
+
{/if}
-
diff --git a/app/src/routes/explorer/+page.svelte b/app/src/routes/explorer/+page.svelte
index 540820b..9970799 100644
--- a/app/src/routes/explorer/+page.svelte
+++ b/app/src/routes/explorer/+page.svelte
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import GameMap from '$lib/components/GameMap.svelte';
import WikiFiche from '$lib/components/WikiFiche.svelte';
+ import Seo from '$lib/components/Seo.svelte';
import { t } from '$lib/i18n/store.svelte';
import { REGIONS } from '$lib/map/regions';
import type { ExplorerLieu, Region } from '$lib/types';
@@ -44,9 +45,7 @@
});
-
- {t().app.titre} — {t().explorer.titre}
-
+
@@ -121,6 +120,7 @@
flex-direction: column;
position: relative;
min-height: 0;
+ overflow: hidden; /* la partie repliée du sheet ne doit pas agrandir la page */
}
.filtres {
@@ -152,7 +152,8 @@
.carte-explorer {
flex: 1;
min-height: 0;
- padding: 0.6rem;
+ padding: 0.6rem 0.6rem 3.6rem; /* réserve la bande de la poignée du sheet :
+ la fiche repliée ne recouvre jamais la carte, les sauts ni l'attribution */
}
.erreur {
@@ -160,7 +161,8 @@
color: var(--oki-encre-doux);
}
- /* Bottom sheet mobile */
+ /* Bottom sheet mobile : overlay dont les événements sont scopés à lui-même.
+ Glisse --dur-mesure --ease-ka (cadence gwoka), coupée en reduced-motion. */
.fiche {
position: absolute;
left: 0;
@@ -173,7 +175,7 @@
box-shadow: 0 -4px 18px rgba(11, 61, 46, 0.2);
padding: 1rem;
transform: translateY(calc(100% - 3.2rem));
- transition: transform 0.25s ease;
+ transition: transform var(--dur-mesure) var(--ease-ka);
}
.fiche.ouvert {
@@ -237,21 +239,32 @@
font-size: 0.92rem;
}
- /* Panneau latéral desktop */
+ /* Desktop : panneau latéral droit EN FLUX (grille) — plus aucune bande
+ verticale par-dessus la carte, toute la carte reste cliquable. */
@media (min-width: 768px) {
- .fiche {
- left: auto;
- top: 0;
- right: 0;
- bottom: 0;
- width: 380px;
- max-height: none;
- border-radius: var(--oki-radius) 0 0 var(--oki-radius);
- transform: translateX(calc(100% - 3.2rem));
+ .explorer {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 360px;
+ grid-template-rows: auto minmax(0, 1fr);
}
- .fiche.ouvert {
- transform: translateX(0);
+ .filtres {
+ grid-column: 1 / -1;
+ }
+
+ .carte-explorer {
+ padding: 0.8rem;
+ min-height: 0;
+ }
+
+ .fiche {
+ position: static;
+ transform: none;
+ max-height: none;
+ min-height: 0;
+ border-radius: 0;
+ border-left: 1px solid var(--oki-sable-fonce);
+ box-shadow: none;
}
}
diff --git a/app/src/routes/jeu/[mode]/+page.svelte b/app/src/routes/jeu/[mode]/+page.svelte
index a05c1bd..c0b0eda 100644
--- a/app/src/routes/jeu/[mode]/+page.svelte
+++ b/app/src/routes/jeu/[mode]/+page.svelte
@@ -1,13 +1,20 @@
-
- {t().app.titre} — {t().modes[mode]}
-
+
{#if bilan}
-
+
{t().defi.bilan}
+
+
+
- {defi.total}
+ {defi.total}
+ {defi.total}
{t().defi.scoreTotal}
@@ -211,7 +165,7 @@
{#each defi.manches as m, i (m.place.id)}
- -
+
-
{i + 1}. {m.result.nom}
{t().regions[m.result.region]}
{m.result.score} {t().resultat.points}
@@ -233,16 +187,16 @@
{:else if chargement}
-
+
{:else if erreur}
-
+
{t().jeu.erreur}
{:else if place}
-
+
{#if result}
@@ -304,261 +270,7 @@
{t().jeu.valider}
- {#if indicesMontres.length > 0}
-
- {/if}
- {#if !guess}
-
{t().jeu.poserMarqueur}
- {/if}
{/if}
{/if}
-
diff --git a/app/static/robots.txt b/app/static/robots.txt
new file mode 100644
index 0000000..59e0726
--- /dev/null
+++ b/app/static/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://jwe.o-k-i.net/sitemap.xml
diff --git a/app/static/sitemap.xml b/app/static/sitemap.xml
new file mode 100644
index 0000000..f0b34cb
--- /dev/null
+++ b/app/static/sitemap.xml
@@ -0,0 +1,11 @@
+
+
+
+ https://jwe.o-k-i.net/
+ 2026-07-21
+
+
+ https://jwe.o-k-i.net/explorer
+ 2026-07-21
+
+