From 9b867243fd5216e34d35e5654e7ce80f48128b8a Mon Sep 17 00:00:00 2001 From: sucupira Date: Tue, 21 Jul 2026 16:32:47 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20refonte=20layout=20viewport=20+=20passe?= =?UTF-8?q?=20motion=20+=20finitions=20SEO/s=C3=A9curit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layout app-style 100dvh : zéro scroll desktop (accueil, jeu, bilan, explorer), zéro superposition mobile (sauts sortis de la carte, badges consolidés en une puce, barre d'actions opaque z-indexée, indices en panneau interne, sheet explorer dégagé) - CSS jeu mutualisé (jeu.css) + partager() factorisé (lib/partage.ts) - Motion gwoka : reveals syncopés, révélation designée (ligne de distance animée, score en count-up, marqueur qui tombe), transitions d'état, bilan en cascade - SEO : Seo.svelte (canonical, OG, Twitter), robots.txt, sitemap.xml - Sécurité : hooks.server.ts (CSP, nosniff, DENY, Referrer-Policy, Permissions-Policy) - Dette : 4 clés i18n mortes, zip debug ignoré - Vérifié puppeteer : mobile 390x844 (0 overlap, 0 scroll), desktop 1440x900 (0 scroll même +2 indices), révélation complète, zéro erreur console/CSP --- .gitignore | 4 + app/src/app.html | 5 + app/src/hooks.server.ts | 43 ++ app/src/lib/components/CarteRegions.svelte | 15 +- app/src/lib/components/GameMap.svelte | 169 ++++++- app/src/lib/components/PhotoPanel.svelte | 1 + app/src/lib/components/ResultPanel.svelte | 7 +- app/src/lib/components/Seo.svelte | 41 ++ app/src/lib/i18n/fr.ts | 6 +- app/src/lib/i18n/gcf.ts | 6 +- app/src/lib/motion/countup.ts | 56 +++ app/src/lib/motion/reveal.ts | 44 ++ app/src/lib/motion/tokens.ts | 42 ++ app/src/lib/motion/transitions.ts | 20 + app/src/lib/partage.ts | 61 +++ app/src/lib/site.ts | 3 + app/src/lib/styles/jeu.css | 514 +++++++++++++++++++++ app/src/lib/styles/oki.css | 83 +++- app/src/routes/+layout.svelte | 3 +- app/src/routes/+page.svelte | 171 ++++++- app/src/routes/defi/[seed]/+page.svelte | 393 ++-------------- app/src/routes/explorer/+page.svelte | 49 +- app/src/routes/jeu/[mode]/+page.svelte | 370 ++------------- app/static/robots.txt | 4 + app/static/sitemap.xml | 11 + 25 files changed, 1368 insertions(+), 753 deletions(-) create mode 100644 app/src/hooks.server.ts create mode 100644 app/src/lib/components/Seo.svelte create mode 100644 app/src/lib/motion/countup.ts create mode 100644 app/src/lib/motion/reveal.ts create mode 100644 app/src/lib/motion/tokens.ts create mode 100644 app/src/lib/motion/transitions.ts create mode 100644 app/src/lib/partage.ts create mode 100644 app/src/lib/site.ts create mode 100644 app/src/lib/styles/jeu.css create mode 100644 app/static/robots.txt create mode 100644 app/static/sitemap.xml 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} {t().defi.scoreTotal}

@@ -214,7 +168,7 @@
    {#each defi.manches as m, i (m.place.id)} -
  1. +
  2. {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}
@@ -260,6 +214,13 @@ {:else if place.photo} {/if} + {#if !result && indicesMontres.length > 0} + + {/if}
@@ -270,14 +231,19 @@ interactive={!result} onvalidate={() => void soumettre(false)} /> - {#if !defiTermine} - {t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5 - {/if} - {t().defi.titreSeed} {seed} - {#if timerActif && !result} -
- -
+
+ {#if !defiTermine} + {t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5 + {/if} + {t().defi.titreSeed} {seed} + {#if timerActif && !result} + + + + {/if} +
+ {#if !result && !guess} +

{t().jeu.poserMarqueur}

{/if}
@@ -307,284 +273,9 @@ {t().jeu.valider}
- {#if indicesMontres.length > 0} - - {/if} - {#if !guess} -

{t().jeu.poserMarqueur}

- {/if} {/if} {:else} -

{t().jeu.charger}

+

{t().jeu.charger}

{/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} {t().defi.scoreTotal}

@@ -211,7 +165,7 @@
    {#each defi.manches as m, i (m.place.id)} -
  1. +
  2. {i + 1}. {m.result.nom} {t().regions[m.result.region]} {m.result.score} {t().resultat.points} @@ -233,16 +187,16 @@

{:else if chargement} -

{t().jeu.charger}

+

{t().jeu.charger}

{:else if erreur} -
+

{t().jeu.erreur}

{:else if place} -
+
{#if result}
@@ -258,6 +212,13 @@ {:else if place.photo} {/if} + {#if !result && indicesMontres.length > 0} + + {/if}
@@ -268,13 +229,18 @@ interactive={!result} onvalidate={() => void soumettre(false)} /> - {#if estDefi && !defiTermine} - {t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5 - {/if} - {#if timerActif && !result} -
- -
+
+ {#if estDefi && !defiTermine} + {t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5 + {/if} + {#if timerActif && !result} + + + + {/if} +
+ {#if !result && !guess} +

{t().jeu.poserMarqueur}

{/if}
@@ -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 + +