feat: refonte layout viewport + passe motion + finitions SEO/sécurité

- 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
This commit is contained in:
sucupira
2026-07-21 16:32:47 -04:00
parent 67142428ac
commit 9b867243fd
25 changed files with 1368 additions and 753 deletions
+13 -2
View File
@@ -125,6 +125,8 @@
<script lang="ts">
import { t } from '$lib/i18n/store.svelte';
import { reveal } from '$lib/motion/reveal';
import { syncopatedDelay } from '$lib/motion/tokens';
interface Props {
/** Région sélectionnée (liaison bidirectionnelle) — ignorée si `selectionnable` est faux. */
@@ -133,13 +135,19 @@
selectionnable?: boolean;
marqueurs?: MarqueurCarte[];
attribution?: string;
/**
* Base d'index de la cascade syncopée d'entrée des 4 insets (accueil :
* 5, après les 5 cartes de mode). null (défaut) = pas de cascade.
*/
cascadeBase?: number | null;
}
let {
selection = $bindable<SelectionCarte>(null),
selectionnable = true,
marqueurs = [],
attribution = '© contributeurs OpenStreetMap / données publiques'
attribution = '© contributeurs OpenStreetMap / données publiques',
cascadeBase = null
}: Props = $props();
let survol = $state<Region | null>(null);
@@ -185,11 +193,14 @@
aria-hidden={selectionnable ? 'true' : undefined}
aria-label={selectionnable ? undefined : t().carte.carteRegionsLabel}
>
{#each INSETS as ins (ins.region)}
{#each INSETS as ins, i (ins.region)}
<g
class="region"
class:survolee={selectionnable && survol === ins.region}
class:choisie={selectionnable && selection === ins.region}
data-reveal={cascadeBase === null ? undefined : ''}
style:--d={cascadeBase === null ? undefined : `${syncopatedDelay(cascadeBase + i)}ms`}
use:reveal={cascadeBase !== null}
>
{#each ins.chemins as d (d)}
<path {d} />
+151 -18
View File
@@ -4,6 +4,7 @@
import { t } from '$lib/i18n/store.svelte';
import { MAX_BOUNDS, MIN_ZOOM, REGION_BOUNDS, REGIONS } from '$lib/map/regions';
import styleOki from '$lib/map/style-oki.json';
import { dureeMs, easeKa, syncopatedDelay } from '$lib/motion/tokens';
import type { Coordonnees, Region } from '$lib/types';
import type { Map as MLMap, Marker, StyleSpecification } from 'maplibre-gl';
@@ -51,6 +52,8 @@
let realMarker: Marker | null = null;
let explorerMarkers: Marker[] = [];
let observateurTaille: ResizeObserver | null = null;
let rafLigne: number | null = null; // tracé progressif de la ligne de distance
let pulseAttendu = false; // pulse du marqueur au prochain positionnement (clic / dragend)
const SRC_LIGNE = 'jwe-ligne';
const SRC_PAIRES = 'jwe-paires';
@@ -94,6 +97,13 @@
};
}
function annulerTraceLigne(): void {
if (rafLigne !== null) {
cancelAnimationFrame(rafLigne);
rafLigne = null;
}
}
function dessinerRevelation(): void {
if (!map || !ml || !real) return;
ensureSource(SRC_LIGNE);
@@ -119,25 +129,50 @@
paint: { 'text-color': '#0b3d2e', 'text-halo-color': '#fffdf8', 'text-halo-width': 2 }
});
}
const features: GeoJSON.Feature[] = [];
if (guess) {
features.push(ligneFeature(guess, real, null));
if (distanceKm !== null) {
// Étiquette de distance au milieu de la ligne guess ↔ réel
const positionReelle = real;
/** Features de la ligne guess ↔ réel à la progression donnée (1 = complète). */
function featuresLigne(progression: number): GeoJSON.Feature[] {
if (!guess) return [];
const tete = {
lat: guess.lat + (positionReelle.lat - guess.lat) * progression,
lon: guess.lon + (positionReelle.lon - guess.lon) * progression
};
const features: GeoJSON.Feature[] = [ligneFeature(guess, tete, null)];
if (progression >= 1 && distanceKm !== null) {
// Étiquette de distance au milieu de la ligne, posée à la fin du tracé
features.push(
pointFeature(
{ lat: (guess.lat + real.lat) / 2, lon: (guess.lon + real.lon) / 2 },
{ lat: (guess.lat + positionReelle.lat) / 2, lon: (guess.lon + positionReelle.lon) / 2 },
{ etiquette: `${distanceKm} km` }
)
);
}
return features;
}
annulerTraceLigne();
const dureeTrace = dureeMs('--dur-phrase');
if (guess && !reducedMotion && dureeTrace > 0) {
// La ligne de distance se dessine depuis le guess vers le lieu réel :
// croissance progressive du GeoJSON via rAF (jamais d'état réactif
// par frame), easing --ease-ka, durée lue du token CSS.
const t0 = performance.now();
const tick = (t: number): void => {
const p = Math.min(1, (t - t0) / dureeTrace);
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: featuresLigne(easeKa(p)) });
rafLigne = p < 1 ? requestAnimationFrame(tick) : null;
};
rafLigne = requestAnimationFrame(tick);
} else {
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: featuresLigne(1) });
}
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features });
// Marqueur vert = position réelle ; le marqueur corail du joueur RESTE
// à sa position : la comparaison visuelle guess ↔ réel est le cœur du jeu.
realMarker?.remove();
realMarker = new ml.Marker({ color: '#0b3d2e' }).setLngLat([real.lon, real.lat]).addTo(map);
// Le marqueur « tombe » avec un petit rebond (transform only, keyframes CSS).
if (!reducedMotion) realMarker.getElement().classList.add('tombe');
if (guess) {
const bounds = new ml.LngLatBounds(
@@ -156,6 +191,7 @@
}
function nettoyerRevelation(): void {
annulerTraceLigne();
realMarker?.remove();
realMarker = null;
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: [] });
@@ -171,6 +207,7 @@
guessMarker.on('dragend', () => {
const ll = guessMarker?.getLngLat();
if (ll) {
pulseAttendu = true; // le déplacement au drag est aussi un placement
guess = { lat: ll.lat, lon: ll.lng };
onguess?.(guess);
}
@@ -179,6 +216,17 @@
guessMarker.setLngLat([guess.lon, guess.lat]);
}
guessMarker.setDraggable(interactive && !real);
if (pulseAttendu) {
pulseAttendu = false;
if (!reducedMotion) {
// Feedback de placement : pulse léger (redémarrage forcé si le
// marqueur bouge à nouveau pendant l'animation).
const el = guessMarker.getElement();
el.classList.remove('pulse');
void el.offsetWidth;
el.classList.add('pulse');
}
}
} else {
guessMarker?.remove();
guessMarker = null;
@@ -230,11 +278,15 @@
if (!map || !ml) return;
for (const m of explorerMarkers) m.remove();
explorerMarkers = [];
for (const info of markers) {
for (const [i, info] of markers.entries()) {
const el = document.createElement('button');
el.type = 'button';
el.className = 'marqueur-explorer';
el.setAttribute('aria-label', info.label);
// Cascade syncopée au (re)filtrage : rythme 3+3+2 sur le premier
// cycle, plafonné à 960 ms au-delà (le catalogue peut être long).
// Sans effet en reduced-motion : l'animation est sous gate média.
el.style.animationDelay = `${Math.min(syncopatedDelay(i), 960)}ms`;
const mk = new ml.Marker({ element: el, color: '#175e70' })
.setLngLat([info.lon, info.lat])
.addTo(map!);
@@ -313,6 +365,7 @@
map.addControl(new mod.NavigationControl({ showCompass: false }), 'top-left');
map.on('click', (e) => {
if (!interactive || real) return;
pulseAttendu = true; // feedback au placement du marqueur
guess = { lat: e.lngLat.lat, lon: e.lngLat.lng };
onguess?.(guess);
});
@@ -329,6 +382,7 @@
})();
return () => {
detruit = true;
annulerTraceLigne();
observateurTaille?.disconnect();
observateurTaille = null;
map?.remove();
@@ -392,33 +446,39 @@
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.carte {
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
border-radius: var(--oki-radius);
overflow: hidden;
}
/* Mobile : les sauts sortent de la carte — barre horizontale SOUS la carte,
dimensionnée pour que les 4 régions tiennent sur 360px+ (scroll en repli),
jamais recouverte par l'attribution ni un overlay. */
.sauts {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
padding: 0 0.5rem;
flex-wrap: nowrap;
gap: 0.3rem;
overflow-x: auto;
padding: 0.1rem;
scrollbar-width: thin;
}
.saut {
flex: 0 0 auto;
min-height: 48px; /* cible tactile */
background: rgba(255, 253, 248, 0.94);
color: var(--oki-vert);
border: 1px solid var(--oki-sable-fonce);
border-radius: 999px;
padding: 0.35rem 0.8rem;
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
font-weight: 650;
cursor: pointer;
@@ -429,6 +489,22 @@
background: var(--oki-vert-doux);
}
/* Desktop : overlay bas-gauche, dégagé de l'attribution compacte (bas-droite). */
@media (min-width: 768px) {
.sauts {
position: absolute;
bottom: 0.6rem;
left: 0.6rem;
max-width: calc(100% - 5rem);
padding: 0;
}
.saut {
min-height: 0;
font-size: 0.8rem;
padding: 0.35rem 0.8rem;
} }
:global(.marqueur-explorer) {
width: 26px;
height: 26px;
@@ -443,4 +519,61 @@
:global(.marqueur-explorer:focus-visible) {
background: var(--oki-vert-clair);
}
/* — Motion des marqueurs : transform/opacity uniquement, gate no-preference
(les classes tombe/pulse ne sont posées qu'hors reduced-motion, et les
délais inline de la cascade explorer sont sans effet sans animation). — */
@media (prefers-reduced-motion: no-preference) {
/* Cascade syncopée d'entrée des marqueurs du catalogue explorer */
:global(.marqueur-explorer) {
animation: marqueur-pop var(--dur-mesure) var(--ease-ka) backwards;
}
/* Le marqueur du lieu réel « tombe » avec un petit rebond */
:global(.maplibregl-marker.tombe > svg) {
transform-origin: bottom center;
animation: marqueur-tombe var(--dur-mesure) var(--ease-ka);
}
/* Pulse léger au placement du marqueur de guess */
:global(.maplibregl-marker.pulse > svg) {
transform-origin: bottom center;
animation: marqueur-pulse var(--dur-mesure) var(--ease-ka);
}
}
@keyframes marqueur-pop {
from {
transform: scale(0.6);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes marqueur-tombe {
0% {
transform: translateY(-34px) scale(0.85);
opacity: 0;
}
55% {
transform: translateY(3px) scale(1.03);
opacity: 1;
}
100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes marqueur-pulse {
0% {
transform: scale(1.4);
}
100% {
transform: scale(1);
}
}
</style>
+1
View File
@@ -59,5 +59,6 @@
font-size: 0.72rem;
color: #fff;
background: linear-gradient(transparent, rgba(11, 61, 46, 0.85));
pointer-events: none; /* décoratif : ne bloque jamais les clics */
}
</style>
+6 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import WikiFiche from './WikiFiche.svelte';
import { t } from '$lib/i18n/store.svelte';
import { compte } from '$lib/motion/countup';
import type { GuessResult } from '$lib/types';
interface Props {
@@ -17,7 +18,11 @@
<header class="score-bloc">
<p class="bravo">{result.score > 0 ? t().micro.bravo : t().micro.kiteSaYe}</p>
<p class="score">
<span class="valeur">{result.score}</span>
<!-- Count-up animé (aria-hidden) doublé de la valeur finale pour les
lecteurs d'écran : la section est aria-live, l'animation rAF
écrirait sinon une valeur par frame. -->
<span class="valeur" aria-hidden="true" use:compte={result.score}>{result.score}</span>
<span class="sr-only">{result.score}</span>
<span class="unite">{t().resultat.points}</span>
</p>
{#if result.distanceKm !== null}
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import { SITE_NAME, SITE_URL } from '$lib/site';
interface Props {
/** Titre de la page (sans le nom du site) — « JWE — … » est composé ici. */
title: string;
description: string;
/** Chemin absolu de la route (ex. `/explorer`) — canonical + og:url. */
path: string;
/** URL absolue d'une image og (aucune émise si absente — pas d'image inventée). */
image?: string;
}
let { title, description, path, image }: Props = $props();
const titreComplet = $derived(`${SITE_NAME}${title}`);
const urlCanonique = $derived(`${SITE_URL}${path}`);
</script>
<svelte:head>
<title>{titreComplet}</title>
<meta name="description" content={description} />
<link rel="canonical" href={urlCanonique} />
<meta property="og:type" content="website" />
<meta property="og:url" content={urlCanonique} />
<meta property="og:title" content={titreComplet} />
<meta property="og:description" content={description} />
<meta property="og:locale" content="fr_FR" />
<meta property="og:site_name" content={SITE_NAME} />
{#if image}
<meta property="og:image" content={image} />
{/if}
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
<meta name="twitter:title" content={titreComplet} />
<meta name="twitter:description" content={description} />
{#if image}
<meta name="twitter:image" content={image} />
{/if}
</svelte:head>