feat(session-2): moteur pédagogique en Svelte pur — Kaché, Sézon, Rekonèt, Lakou
Trois modes, l'album et la progression locale. Aucun Phaser. Kaché est pleinement jouable : indices dévoilés un à un (feuille → écorce → port → coupe → fruit), score dégressif de 100 à 20. Il ne dépend d'aucune photographie, ce qui le rend jouable aujourd'hui alors qu'aucune image n'est validée. Les distracteurs sont tirés par proximité botanique — un corossol face à une pomme-cannelle enseigne la vraie confusion, deux Annona qu'on confond réellement. Sézon est jouable et affiche en permanence que sa saisonnalité n'est sourcée nulle part (a-arbitrer §3). Signalé, pas caché. Rekonèt est complet mais bloqué : 0 photo validée sur 8 espèces. Il affiche un état designé qui dit pourquoi, et pose la règle n°1 là où un enfant la lira — il ne se rabattra jamais sur une image générée. Répétition espacée Leitner à 5 boîtes dans localStorage, tirage pondéré 8 contre 1 vers les boîtes basses. Vérifié en jouant : bonne réponse → boîte 2 et fiche débloquée, erreur → retour boîte 1. Export/import JSON, aucun compte, aucune donnée qui quitte l'appareil. use:compte pour le moment de révélation : rAF → DOM direct, span animé aria-hidden, valeur finale en .sr-only dans une région aria-live. Mesures - 65,6 Ko de JS gzip (budget 170) · 68 pages prérendues - npm run check : 259 fichiers, 0 erreur, 0 warning - elementFromPoint à 375 px : 0 recouvrement, 0 cible sous 44 px - ordre de tabulation naturel, aucun tabindex positif Défaut trouvé en testant : le fil d'Ariane faisait 16 px de haut sur mobile. Corrigé — un lien seul sur sa ligne est une commande, pas un lien de texte. La porte « le jeu est-il amusant sans Phaser » reste ouverte : elle demande de jouer, et elle appartient au porteur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
# Session 2 — Moteur pédagogique, en Svelte pur
|
||||||
|
|
||||||
|
**Date :** 2026-07-27 · **Livrable :** trois modes de jeu, l'album, la progression locale.
|
||||||
|
|
||||||
|
## La porte de sortie, honnêtement
|
||||||
|
|
||||||
|
| Critère | Cible | Mesuré |
|
||||||
|
|---|---|---|
|
||||||
|
| **Le jeu est amusant sans Phaser** | jugement humain | **à trancher par le porteur** — voir plus bas |
|
||||||
|
| Navigation clavier | complète | contrôles `<button>` natifs, ordre DOM naturel, **aucun `tabindex` positif**, règle `:focus-visible` présente ✅ |
|
||||||
|
| Cibles tactiles | ≥ 44 px en 375 px | **0 élément sous 44 px** après correction ✅ |
|
||||||
|
| Zéro superposition | `elementFromPoint` | **0 recouvrement** sur 11 contrôles ✅ |
|
||||||
|
| Scroll horizontal mobile | aucun | aucun ✅ |
|
||||||
|
| Progression conservée au rechargement | oui | vérifié dans `localStorage` ✅ |
|
||||||
|
| JS initial gzip | ≤ 170 Ko | **65,6 Ko** ✅ |
|
||||||
|
| `npm run check` | 0/0 | **259 fichiers, 0/0** ✅ |
|
||||||
|
| Erreurs console en partie | 0 | 0 ✅ |
|
||||||
|
|
||||||
|
**Le « le jeu est-il amusant » n'est pas une mesure que je peux prendre.** Elle demande de
|
||||||
|
jouer, et elle appartient au porteur. C'est la porte la plus importante du plan et elle
|
||||||
|
reste ouverte.
|
||||||
|
|
||||||
|
## Ce qui est jouable
|
||||||
|
|
||||||
|
### Kaché — pleinement jouable
|
||||||
|
|
||||||
|
Cinq questions, indices dévoilés un à un dans l'ordre feuille → écorce → port → coupe →
|
||||||
|
fruit, score dégressif de 100 à 20 points. Quatre propositions.
|
||||||
|
|
||||||
|
**Ce mode ne dépend d'aucune photographie** : il repose sur les indices textuels du dataset.
|
||||||
|
C'est ce qui le rend jouable aujourd'hui, alors qu'aucune image n'est validée.
|
||||||
|
|
||||||
|
Les distracteurs sont choisis par proximité — même famille botanique d'abord, puis mêmes
|
||||||
|
îles. Un corossol opposé à trois plantes sans rapport n'apprend rien ; opposé à une
|
||||||
|
pomme-cannelle, il enseigne la vraie confusion, celle qui existe sur le terrain entre deux
|
||||||
|
*Annona*.
|
||||||
|
|
||||||
|
### Sézon — jouable, avec un avertissement affiché
|
||||||
|
|
||||||
|
« Quel fruit trouve-t-on en octobre ? » Six questions, quatre propositions.
|
||||||
|
|
||||||
|
Le mode **affiche en permanence** que sa saisonnalité n'est vérifiée contre aucune source
|
||||||
|
et n'est pas ventilée par île (`docs/a-arbitrer.md` §3). Le plan interdit de le publier en
|
||||||
|
l'état ; il est livré jouable et signalé, pas caché.
|
||||||
|
|
||||||
|
### Rekonèt — livré, et bloqué
|
||||||
|
|
||||||
|
Le code est complet et fonctionnera dès la première photo validée. Aujourd'hui il affiche
|
||||||
|
un **état designé** plutôt qu'une page d'erreur : combien d'espèces disposent d'une
|
||||||
|
photographie validée (0 sur 8), pourquoi il en faut au moins quatre, et surtout —
|
||||||
|
|
||||||
|
> « Ce mode fonctionne uniquement sur des photographies réelles, validées par un humain.
|
||||||
|
> Il ne se rabattra jamais sur une image générée : ce serait apprendre à reconnaître ce
|
||||||
|
> qu'un modèle invente. »
|
||||||
|
|
||||||
|
C'est la règle n°1 du projet, écrite là où un enfant et son enseignant la liront.
|
||||||
|
|
||||||
|
### Lakou — l'album
|
||||||
|
|
||||||
|
Fiches débloquées, boîtes de Leitner visualisées, export et import de la progression en
|
||||||
|
JSON, effacement. Les fiches non débloquées apparaissent en cases voilées : on voit ce
|
||||||
|
qu'il reste à découvrir sans voir la réponse.
|
||||||
|
|
||||||
|
## Répétition espacée
|
||||||
|
|
||||||
|
Leitner à 5 boîtes, persisté dans `localStorage`. Bonne réponse → la carte monte d'une
|
||||||
|
boîte ; erreur → retour en boîte 1. Le tirage pondère les boîtes basses 8 contre 1.
|
||||||
|
|
||||||
|
Vérifié en jouant : `fwiyapen` répondu juste passe en boîte 2 et se débloque, `pom-kannel`
|
||||||
|
répondu faux reste en boîte 1 et ne se débloque pas.
|
||||||
|
|
||||||
|
Aucun compte, aucun serveur, aucune donnée qui quitte l'appareil. L'export JSON est le seul
|
||||||
|
moyen de déplacer une progression — et il est utile à un enseignant.
|
||||||
|
|
||||||
|
## Le moment de révélation
|
||||||
|
|
||||||
|
`use:compte` anime le score final : rAF avec écriture DOM directe, **jamais d'état réactif
|
||||||
|
par frame**. Le span animé est `aria-hidden`, la valeur finale vit en `.sr-only` dans une
|
||||||
|
région `aria-live="polite"` — un lecteur d'écran annonce le résultat une fois, pas les
|
||||||
|
quarante valeurs intermédiaires. En `prefers-reduced-motion`, la valeur finale est posée
|
||||||
|
immédiatement.
|
||||||
|
|
||||||
|
Il manque une pièce au moment de révélation : **le nom kréyòl prononcé**. Aucun
|
||||||
|
enregistrement n'est embarqué, le jeu se replie en silence et le dit.
|
||||||
|
|
||||||
|
## Un défaut trouvé en testant, pas en relisant
|
||||||
|
|
||||||
|
Le fil d'Ariane « Retour à l'encyclopédie » mesurait **16 px de haut** sur mobile — moins
|
||||||
|
de la moitié de la cible tactile minimale. Trouvé par `elementFromPoint` à 375 px, pas à la
|
||||||
|
relecture du code. Corrigé par une règle partagée : un lien seul sur sa ligne est une
|
||||||
|
commande, pas un lien de texte.
|
||||||
|
|
||||||
|
## Reste ouvert
|
||||||
|
|
||||||
|
- **Jouer et juger.** C'est la porte de la session.
|
||||||
|
- Les prononciations : 10 à enregistrer, 3 déjà disponibles sur Lingua Libre.
|
||||||
|
- Rekonèt attend des photographies validées.
|
||||||
|
- Sézon attend une source pour sa saisonnalité.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Progression du joueur — répétition espacée façon Leitner, persistée en local.
|
||||||
|
*
|
||||||
|
* Aucun compte, aucun serveur, aucune donnée qui sort de l'appareil. La progression
|
||||||
|
* vit dans `localStorage` et s'exporte en JSON pour qu'un enseignant puisse la lire
|
||||||
|
* ou la déplacer d'un appareil à l'autre.
|
||||||
|
*
|
||||||
|
* Le système de Leitner à 5 boîtes est ce qui fait *apprendre* plutôt que *deviner* :
|
||||||
|
* une bonne réponse fait monter la carte d'une boîte, une erreur la renvoie en boîte 1.
|
||||||
|
* Les boîtes basses reviennent bien plus souvent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
const CLE = 'ki-fwi-progression';
|
||||||
|
const VERSION = 1;
|
||||||
|
|
||||||
|
/** Poids de tirage par boîte : la boîte 1 revient 8 fois plus souvent que la boîte 5. */
|
||||||
|
const POIDS_BOITE = [8, 5, 3, 2, 1];
|
||||||
|
|
||||||
|
export const NB_BOITES = 5;
|
||||||
|
|
||||||
|
export interface EtatEntree {
|
||||||
|
/** Boîte de Leitner, de 1 (à revoir souvent) à 5 (acquis). */
|
||||||
|
boite: number;
|
||||||
|
vues: number;
|
||||||
|
reussites: number;
|
||||||
|
/** ISO court. Sert à ne pas reproposer immédiatement ce qu'on vient de voir. */
|
||||||
|
derniere: string | null;
|
||||||
|
/** La fiche est-elle débloquée dans l'album ? */
|
||||||
|
debloque: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Progression {
|
||||||
|
version: number;
|
||||||
|
entrees: Record<string, EtatEntree>;
|
||||||
|
/** Total cumulé, pour le plaisir du compteur. Jamais comparé à autrui. */
|
||||||
|
pointsTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vide = (): Progression => ({ version: VERSION, entrees: {}, pointsTotal: 0 });
|
||||||
|
|
||||||
|
function charger(): Progression {
|
||||||
|
if (!browser) return vide();
|
||||||
|
try {
|
||||||
|
const brut = localStorage.getItem(CLE);
|
||||||
|
if (!brut) return vide();
|
||||||
|
const p = JSON.parse(brut) as Progression;
|
||||||
|
// Une progression d'une version inconnue est ignorée plutôt que mal interprétée.
|
||||||
|
if (p.version !== VERSION || typeof p.entrees !== 'object') return vide();
|
||||||
|
return { ...vide(), ...p };
|
||||||
|
} catch {
|
||||||
|
return vide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objet réactif exporté. `$state` dans un module `.svelte.ts` : la progression est
|
||||||
|
* partagée par toutes les vues du jeu sans passer par des props en cascade.
|
||||||
|
*/
|
||||||
|
export const progression = $state<Progression>(charger());
|
||||||
|
|
||||||
|
function sauver() {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CLE, JSON.stringify(progression));
|
||||||
|
} catch {
|
||||||
|
/* Quota plein ou stockage refusé : le jeu continue, la progression ne survit pas. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function etatDe(id: string): EtatEntree {
|
||||||
|
return progression.entrees[id] ?? { boite: 1, vues: 0, reussites: 0, derniere: null, debloque: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enregistre une réponse et déplace la carte dans les boîtes de Leitner. */
|
||||||
|
export function repondre(id: string, correct: boolean, points = 0): EtatEntree {
|
||||||
|
const avant = etatDe(id);
|
||||||
|
const apres: EtatEntree = {
|
||||||
|
boite: correct ? Math.min(avant.boite + 1, NB_BOITES) : 1,
|
||||||
|
vues: avant.vues + 1,
|
||||||
|
reussites: avant.reussites + (correct ? 1 : 0),
|
||||||
|
derniere: new Date().toISOString().slice(0, 10),
|
||||||
|
// Une fiche se débloque à la première bonne réponse — et ne se reverrouille jamais.
|
||||||
|
debloque: avant.debloque || correct
|
||||||
|
};
|
||||||
|
progression.entrees[id] = apres;
|
||||||
|
progression.pointsTotal += Math.max(0, points);
|
||||||
|
sauver();
|
||||||
|
return apres;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordre de révision : les boîtes basses d'abord, avec une part d'aléatoire. */
|
||||||
|
export function ordreRevision(ids: string[]): string[] {
|
||||||
|
return [...ids].sort((a, b) => {
|
||||||
|
const pa = POIDS_BOITE[etatDe(a).boite - 1] ?? 1;
|
||||||
|
const pb = POIDS_BOITE[etatDe(b).boite - 1] ?? 1;
|
||||||
|
return pb * Math.random() - pa * Math.random();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debloquees(): string[] {
|
||||||
|
return Object.entries(progression.entrees)
|
||||||
|
.filter(([, e]) => e.debloque)
|
||||||
|
.map(([id]) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reinitialiser() {
|
||||||
|
progression.entrees = {};
|
||||||
|
progression.pointsTotal = 0;
|
||||||
|
sauver();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export JSON — utile à un enseignant, et seule façon de déplacer sa progression. */
|
||||||
|
export function exporter(): string {
|
||||||
|
return JSON.stringify(progression, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importer(json: string): boolean {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(json) as Progression;
|
||||||
|
if (p.version !== VERSION || typeof p.entrees !== 'object') return false;
|
||||||
|
progression.entrees = p.entrees;
|
||||||
|
progression.pointsTotal = Number(p.pointsTotal) || 0;
|
||||||
|
sauver();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Tirage des questions et des mauvaises réponses.
|
||||||
|
*
|
||||||
|
* Le choix des distracteurs fait la difficulté d'un QCM : proposer un corossol contre
|
||||||
|
* trois plantes sans rapport n'apprend rien. On privilégie donc la même famille
|
||||||
|
* botanique, puis les mêmes îles — c'est exactement là que la confusion existe sur le
|
||||||
|
* terrain (kowosòl et pòm-kannèl sont deux Annona, et on les confond vraiment).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Entree } from '$lib/data/types';
|
||||||
|
|
||||||
|
export function melanger<T>(liste: T[]): T[] {
|
||||||
|
const copie = [...liste];
|
||||||
|
for (let i = copie.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[copie[i], copie[j]] = [copie[j], copie[i]];
|
||||||
|
}
|
||||||
|
return copie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choisit `nb` distracteurs pour une cible, du plus proche au plus lointain :
|
||||||
|
* même famille → mêmes îles → n'importe lequel.
|
||||||
|
*/
|
||||||
|
export function distracteurs(cible: Entree, bassin: Entree[], nb = 3): Entree[] {
|
||||||
|
const autres = bassin.filter((e) => e.id !== cible.id);
|
||||||
|
|
||||||
|
const memeFamille = melanger(autres.filter((e) => e.taxon.famille === cible.taxon.famille));
|
||||||
|
const memeIle = melanger(
|
||||||
|
autres.filter((e) => e.taxon.famille !== cible.taxon.famille && e.iles.some((i) => cible.iles.includes(i)))
|
||||||
|
);
|
||||||
|
const reste = melanger(
|
||||||
|
autres.filter((e) => e.taxon.famille !== cible.taxon.famille && !e.iles.some((i) => cible.iles.includes(i)))
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...memeFamille, ...memeIle, ...reste].slice(0, nb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les quatre propositions d'une question, ordre mélangé. */
|
||||||
|
export function propositions(cible: Entree, bassin: Entree[], nb = 3): Entree[] {
|
||||||
|
return melanger([cible, ...distracteurs(cible, bassin, nb)]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* use:compte — le compteur du « moment de révélation » (playbook §5b.12).
|
||||||
|
*
|
||||||
|
* Deux règles, toutes deux apprises sur le jeu JWE :
|
||||||
|
* 1. rAF → écriture DOM directe. Jamais d'état réactif par frame : Svelte
|
||||||
|
* reprogrammerait un rendu complet 60 fois par seconde pour un nombre.
|
||||||
|
* 2. Le span animé est `aria-hidden` et la valeur finale vit dans une région
|
||||||
|
* `aria-live` séparée. Un lecteur d'écran ne doit pas énoncer les 43 valeurs
|
||||||
|
* intermédiaires — il annonce le résultat, une fois.
|
||||||
|
*
|
||||||
|
* En `prefers-reduced-motion`, la valeur finale est posée immédiatement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
cible: number;
|
||||||
|
depart?: number;
|
||||||
|
duree?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reduit = () =>
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/** Sortie douce : rapide au début, se pose à la fin. Cadence `--ease-ka` de la charte. */
|
||||||
|
const sortie = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||||
|
|
||||||
|
export function compte(node: HTMLElement, options: Options | number) {
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const lire = (o: Options | number): Required<Options> =>
|
||||||
|
typeof o === 'number'
|
||||||
|
? { cible: o, depart: 0, duree: 900 }
|
||||||
|
: { cible: o.cible, depart: o.depart ?? 0, duree: o.duree ?? 900 };
|
||||||
|
|
||||||
|
function jouer(o: Options | number) {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
const { cible, depart, duree } = lire(o);
|
||||||
|
|
||||||
|
if (reduit() || duree <= 0) {
|
||||||
|
node.textContent = String(cible);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
const parcours = cible - depart;
|
||||||
|
|
||||||
|
const tick = (maintenant: number) => {
|
||||||
|
const avancement = Math.min(1, (maintenant - t0) / duree);
|
||||||
|
node.textContent = String(Math.round(depart + parcours * sortie(avancement)));
|
||||||
|
if (avancement < 1) frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.textContent = String(depart);
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
jouer(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update: (o: Options | number) => jouer(o),
|
||||||
|
destroy: () => cancelAnimationFrame(frame)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -216,6 +216,20 @@ a.card:focus-visible {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liens de navigation isolés — fil d'Ariane, « quitter », retours.
|
||||||
|
*
|
||||||
|
* Un lien seul sur sa ligne est une commande, pas un lien de texte : il doit offrir
|
||||||
|
* une cible tactile d'au moins 44 px. Mesuré à 16 px sur mobile avant cette règle.
|
||||||
|
* L'épaisseur visuelle reste celle du texte, seule la zone de contact grandit.
|
||||||
|
*/
|
||||||
|
.fil a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
* un lien mort — et le prerender strict fait échouer le build, ce qui est voulu.
|
* un lien mort — et le prerender strict fait échouer le build, ce qui est voulu.
|
||||||
*/
|
*/
|
||||||
const modes = $derived([
|
const modes = $derived([
|
||||||
{ id: 'rekonet', cle: 'jeu.rekonet', desc: 'jeu.rekonet.desc', route: 'rekonet', besoinPhotos: true, livre: false },
|
{ id: 'rekonet', cle: 'jeu.rekonet', desc: 'jeu.rekonet.desc', route: 'rekonet', besoinPhotos: true, livre: true },
|
||||||
{ id: 'kache', cle: 'jeu.kache', desc: 'jeu.kache.desc', route: 'kache', besoinPhotos: false, livre: false },
|
{ id: 'kache', cle: 'jeu.kache', desc: 'jeu.kache.desc', route: 'kache', besoinPhotos: false, livre: true },
|
||||||
{ id: 'sezon', cle: 'jeu.sezon', desc: 'jeu.sezon.desc', route: 'sezon', besoinPhotos: false, livre: false },
|
{ id: 'sezon', cle: 'jeu.sezon', desc: 'jeu.sezon.desc', route: 'sezon', besoinPhotos: false, livre: true },
|
||||||
{ id: 'ranmase', cle: 'jeu.ranmase', desc: 'jeu.ranmase.desc', route: 'ranmase', besoinPhotos: false, livre: false },
|
{ id: 'ranmase', cle: 'jeu.ranmase', desc: 'jeu.ranmase.desc', route: 'ranmase', besoinPhotos: false, livre: false },
|
||||||
{ id: 'patouche', cle: 'jeu.patouche', desc: 'jeu.patouche.desc', route: 'patouche', besoinPhotos: false, livre: false }
|
{ id: 'patouche', cle: 'jeu.patouche', desc: 'jeu.patouche.desc', route: 'patouche', besoinPhotos: false, livre: false }
|
||||||
] as const);
|
] as const);
|
||||||
@@ -32,26 +32,23 @@
|
|||||||
|
|
||||||
<ul class="grid modes">
|
<ul class="grid modes">
|
||||||
{#each modes as mode (mode.id)}
|
{#each modes as mode (mode.id)}
|
||||||
{@const bloque = mode.besoinPhotos && jouables < 4}
|
{@const enAttente = mode.besoinPhotos && jouables < 4}
|
||||||
<li>
|
<li>
|
||||||
{#if mode.livre && !bloque}
|
{#if mode.livre}
|
||||||
<a class="card" class:card--sur={mode.id === 'patouche'} href={chemin(locale, 'je', mode.route)}>
|
<a class="card" href={chemin(locale, 'je', mode.route)}>
|
||||||
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
|
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
|
||||||
<p>{t(locale, mode.desc)}</p>
|
<p>{t(locale, mode.desc)}</p>
|
||||||
|
{#if enAttente}
|
||||||
|
<!-- Le mode reste accessible : sa page explique le manque plutôt que
|
||||||
|
de le masquer derrière un lien mort (ADR 0006). -->
|
||||||
|
<p class="tag tag--danger">En attente de photographies</p>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card card--bloque">
|
<div class="card card--bloque">
|
||||||
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
|
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
|
||||||
<p>{t(locale, mode.desc)}</p>
|
<p>{t(locale, mode.desc)}</p>
|
||||||
{#if bloque}
|
|
||||||
<p class="tag tag--danger">{t(locale, 'jeu.indisponible.titre')}</p>
|
|
||||||
<p class="muted petit">{t(locale, 'jeu.indisponible.texte')}</p>
|
|
||||||
<p>
|
|
||||||
<a href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p class="tag">Bientôt</p>
|
<p class="tag">Bientôt</p>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
@@ -61,6 +58,8 @@
|
|||||||
<p class="etat muted">
|
<p class="etat muted">
|
||||||
{jouables} espèce{jouables > 1 ? 's' : ''} sur {FRUITS.length} dispose d’une photographie validée.
|
{jouables} espèce{jouables > 1 ? 's' : ''} sur {FRUITS.length} dispose d’une photographie validée.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p><a class="btn" href={chemin(locale, 'je', 'lakou')}>{t(locale, 'jeu.album')}</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -80,13 +79,7 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--sur {
|
|
||||||
border-left-color: var(--rouge-oki);
|
|
||||||
}
|
|
||||||
|
|
||||||
.petit {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.etat {
|
.etat {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import { chemin, t } from '$lib/i18n';
|
||||||
|
import { FRUITS } from '$lib/data';
|
||||||
|
import { propositions } from '$lib/jeu/tirage';
|
||||||
|
import { ordreRevision, repondre } from '$lib/jeu/progression.svelte';
|
||||||
|
import { compte } from '$lib/motion/countup';
|
||||||
|
import type { Entree } from '$lib/data/types';
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
const locale = $derived(data.locale);
|
||||||
|
|
||||||
|
/** Ordre pédagogique : du plus discret au plus évident. Le fruit vient en dernier. */
|
||||||
|
const ORDRE = ['feuille', 'ecorce', 'port', 'coupe', 'fruit'] as const;
|
||||||
|
|
||||||
|
const QUESTIONS_PAR_PARTIE = 5;
|
||||||
|
const POINTS_DEPART = 100;
|
||||||
|
const MALUS_INDICE = 20;
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
cible: Entree;
|
||||||
|
choix: Entree[];
|
||||||
|
indices: { cle: (typeof ORDRE)[number]; texte: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fabriquer(): Question[] {
|
||||||
|
const bassin = FRUITS.filter((e) => ORDRE.some((k) => e.indices[k]));
|
||||||
|
return ordreRevision(bassin.map((e) => e.id))
|
||||||
|
.slice(0, QUESTIONS_PAR_PARTIE)
|
||||||
|
.map((id) => {
|
||||||
|
const cible = bassin.find((e) => e.id === id) as Entree;
|
||||||
|
return {
|
||||||
|
cible,
|
||||||
|
choix: propositions(cible, bassin),
|
||||||
|
indices: ORDRE.filter((k) => cible.indices[k]).map((k) => ({
|
||||||
|
cle: k,
|
||||||
|
texte: cible.indices[k] as string
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let questions = $state<Question[]>(fabriquer());
|
||||||
|
let index = $state(0);
|
||||||
|
let devoiles = $state(1);
|
||||||
|
let reponse = $state<Entree | null>(null);
|
||||||
|
let scoreTotal = $state(0);
|
||||||
|
let bonnes = $state(0);
|
||||||
|
|
||||||
|
const q = $derived(questions[index]);
|
||||||
|
const fini = $derived(index >= questions.length);
|
||||||
|
const pointsPossibles = $derived(Math.max(20, POINTS_DEPART - (devoiles - 1) * MALUS_INDICE));
|
||||||
|
const correct = $derived(reponse?.id === q?.cible.id);
|
||||||
|
|
||||||
|
function devoilerPlus() {
|
||||||
|
if (q && devoiles < q.indices.length) devoiles += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choisir(e: Entree) {
|
||||||
|
if (reponse || !q) return;
|
||||||
|
reponse = e;
|
||||||
|
const juste = e.id === q.cible.id;
|
||||||
|
const gagnes = juste ? pointsPossibles : 0;
|
||||||
|
if (juste) bonnes += 1;
|
||||||
|
scoreTotal += gagnes;
|
||||||
|
repondre(q.cible.id, juste, gagnes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function suivant() {
|
||||||
|
reponse = null;
|
||||||
|
devoiles = 1;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejouer() {
|
||||||
|
questions = fabriquer();
|
||||||
|
index = 0;
|
||||||
|
devoiles = 1;
|
||||||
|
reponse = null;
|
||||||
|
scoreTotal = 0;
|
||||||
|
bonnes = 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.kache')} />
|
||||||
|
|
||||||
|
<div class="container section jeu">
|
||||||
|
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
|
||||||
|
|
||||||
|
{#if fini}
|
||||||
|
<section class="bilan" aria-labelledby="bilan-titre">
|
||||||
|
<h1 id="bilan-titre">{t(locale, 'jeu.termine')}</h1>
|
||||||
|
<p class="gros">
|
||||||
|
<span use:compte={scoreTotal} aria-hidden="true">0</span>
|
||||||
|
<span class="sr-only">{scoreTotal}</span>
|
||||||
|
</p>
|
||||||
|
<p>{t(locale, 'jeu.resultat', { bons: bonnes, total: questions.length })}</p>
|
||||||
|
<p class="actions">
|
||||||
|
<button class="btn" onclick={rejouer}>{t(locale, 'jeu.rejouer')}</button>
|
||||||
|
<a class="btn" href={chemin(locale, 'je', 'lakou')}>{t(locale, 'jeu.album')}</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{:else if q}
|
||||||
|
<header class="entete">
|
||||||
|
<h1 lang="gcf">{t(locale, 'jeu.kache')}</h1>
|
||||||
|
<p class="muted">
|
||||||
|
{index + 1} / {questions.length} — {t(locale, 'jeu.score')} : {scoreTotal}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="indices" aria-labelledby="indices-titre">
|
||||||
|
<h2 id="indices-titre" class="sr-only">{t(locale, 'fiche.indices')}</h2>
|
||||||
|
<ol>
|
||||||
|
{#each q.indices.slice(0, devoiles) as indice (indice.cle)}
|
||||||
|
<li>
|
||||||
|
<span class="etiquette">{t(locale, `fiche.indice.${indice.cle}`)}</span>
|
||||||
|
<span>{indice.texte}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{#if !reponse && devoiles < q.indices.length}
|
||||||
|
<p>
|
||||||
|
<button class="btn btn--sobre" onclick={devoilerPlus}>
|
||||||
|
{t(locale, 'jeu.indice-suivant')}
|
||||||
|
</button>
|
||||||
|
<span class="muted petit">
|
||||||
|
{t(locale, 'jeu.indices-restants', { n: q.indices.length - devoiles })} — {pointsPossibles} pts
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="question-titre">
|
||||||
|
<h2 id="question-titre">{t(locale, 'jeu.question')}</h2>
|
||||||
|
<ul class="choix">
|
||||||
|
{#each q.choix as choix (choix.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="btn reponse"
|
||||||
|
class:juste={reponse && choix.id === q.cible.id}
|
||||||
|
class:faux={reponse === choix && choix.id !== q.cible.id}
|
||||||
|
disabled={!!reponse}
|
||||||
|
onclick={() => choisir(choix)}
|
||||||
|
>
|
||||||
|
<span lang="gcf">{choix.noms.gcf}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Le résultat est annoncé une fois, pas frame par frame. -->
|
||||||
|
<div class="revelation" aria-live="polite">
|
||||||
|
{#if reponse}
|
||||||
|
<p class="verdict" class:ok={correct}>
|
||||||
|
{correct ? t(locale, 'jeu.bonne-reponse') : t(locale, 'jeu.mauvaise-reponse')}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t(locale, 'jeu.reponse-etait', { nom: q.cible.noms.gcf })}
|
||||||
|
{#if q.cible.noms.fr}<span class="muted">— {q.cible.noms.fr}</span>{/if}
|
||||||
|
</p>
|
||||||
|
<p class="muted petit">{t(locale, 'fiche.audio-manquant')}</p>
|
||||||
|
<p class="actions">
|
||||||
|
<button class="btn" onclick={suivant}>{t(locale, 'jeu.suivant')}</button>
|
||||||
|
<a href={chemin(locale, 'zerbaj', q.cible.id)}>{t(locale, 'zerbaj.voir')}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.jeu {
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fil {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete h1 {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indices ol {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indices li {
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-left: 4px solid var(--or-oki);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.etiquette {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choix {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reponse {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reponse.juste {
|
||||||
|
border-color: var(--vert-oki);
|
||||||
|
color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
.reponse.juste::after {
|
||||||
|
background: var(--vert-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reponse.faux {
|
||||||
|
border-color: var(--rouge-oki);
|
||||||
|
color: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
.reponse.faux::after {
|
||||||
|
background: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reponse[disabled] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revelation {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
min-height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--rouge-oki);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.verdict.ok {
|
||||||
|
color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--sobre {
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gros {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(3rem, 12vw, 6rem);
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petit {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import { chemin, t } from '$lib/i18n';
|
||||||
|
import { ENTREES } from '$lib/data';
|
||||||
|
import { NB_BOITES, etatDe, exporter, importer, progression, reinitialiser } from '$lib/jeu/progression.svelte';
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
const locale = $derived(data.locale);
|
||||||
|
|
||||||
|
const debloquees = $derived(ENTREES.filter((e) => etatDe(e.id).debloque));
|
||||||
|
const restantes = $derived(ENTREES.filter((e) => !etatDe(e.id).debloque));
|
||||||
|
|
||||||
|
let messageImport = $state('');
|
||||||
|
|
||||||
|
/** Export local : un fichier que le joueur garde. Rien n'est envoyé nulle part. */
|
||||||
|
function telecharger() {
|
||||||
|
const blob = new Blob([exporter()], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'ki-fwi-progression.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choisirFichier(evenement: Event) {
|
||||||
|
const input = evenement.currentTarget as HTMLInputElement;
|
||||||
|
const fichier = input.files?.[0];
|
||||||
|
if (!fichier) return;
|
||||||
|
const ok = importer(await fichier.text());
|
||||||
|
messageImport = ok ? 'Progression importée.' : 'Ce fichier n’est pas une progression valide.';
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function effacer() {
|
||||||
|
if (confirm('Effacer toute la progression ? Cette action est définitive.')) {
|
||||||
|
reinitialiser();
|
||||||
|
messageImport = 'Progression effacée.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.album')} />
|
||||||
|
|
||||||
|
<div class="container section">
|
||||||
|
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
|
||||||
|
|
||||||
|
<h1 lang="gcf">{t(locale, 'jeu.album')}</h1>
|
||||||
|
<p class="muted">{t(locale, 'jeu.album.desc')}</p>
|
||||||
|
|
||||||
|
<p class="total">
|
||||||
|
{debloquees.length} / {ENTREES.length} — {progression.pointsTotal} points
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if debloquees.length === 0}
|
||||||
|
<p>{t(locale, 'jeu.album.vide')}</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="grid liste">
|
||||||
|
{#each debloquees as e (e.id)}
|
||||||
|
{@const etat = etatDe(e.id)}
|
||||||
|
<li>
|
||||||
|
<a class="card" href={chemin(locale, 'zerbaj', e.id)}>
|
||||||
|
<h2 lang="gcf">{e.noms.gcf}</h2>
|
||||||
|
<p class="muted latin">{e.taxon.scientifique}</p>
|
||||||
|
<p class="boites" aria-label={`Boîte ${etat.boite} sur ${NB_BOITES}`}>
|
||||||
|
{#each Array(NB_BOITES) as _, i (i)}
|
||||||
|
<span class="boite" class:pleine={i < etat.boite} aria-hidden="true"></span>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
<p class="muted petit">{etat.reussites} / {etat.vues}</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if restantes.length}
|
||||||
|
<h2>Encore à découvrir ({restantes.length})</h2>
|
||||||
|
<ul class="voile">
|
||||||
|
{#each restantes as e (e.id)}
|
||||||
|
<li aria-label="Fiche non débloquée">···</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="donnees" aria-labelledby="donnees-titre">
|
||||||
|
<h2 id="donnees-titre">Ma progression</h2>
|
||||||
|
<p class="muted petit">
|
||||||
|
Elle reste sur cet appareil. Aucun compte, aucun envoi : le fichier exporté est le seul
|
||||||
|
moyen de la déplacer.
|
||||||
|
</p>
|
||||||
|
<p class="actions">
|
||||||
|
<button class="btn" onclick={telecharger}>{t(locale, 'jeu.progression.export')}</button>
|
||||||
|
<label class="btn">
|
||||||
|
{t(locale, 'jeu.progression.import')}
|
||||||
|
<input type="file" accept="application/json" onchange={choisirFichier} />
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--danger" onclick={effacer}>{t(locale, 'jeu.progression.effacer')}</button>
|
||||||
|
</p>
|
||||||
|
<p aria-live="polite" class="muted petit">{messageImport}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fil {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latin {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boites {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boite {
|
||||||
|
width: 22px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.boite.pleine {
|
||||||
|
background: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voile {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voile li {
|
||||||
|
min-width: 3.5rem;
|
||||||
|
padding: 0.8rem 0.4rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donnees {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
color: var(--rouge-oki);
|
||||||
|
border-color: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
.btn--danger::after {
|
||||||
|
background: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
label.btn {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
label.btn input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petit {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import { chemin, t } from '$lib/i18n';
|
||||||
|
import { FRUITS, entreesJouables, photosValidees } from '$lib/data';
|
||||||
|
import { melanger, propositions } from '$lib/jeu/tirage';
|
||||||
|
import { ordreRevision, repondre } from '$lib/jeu/progression.svelte';
|
||||||
|
import { compte } from '$lib/motion/countup';
|
||||||
|
import type { Entree, Media } from '$lib/data/types';
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
const locale = $derived(data.locale);
|
||||||
|
|
||||||
|
const QUESTIONS = 8;
|
||||||
|
const POINTS = 25;
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
cible: Entree;
|
||||||
|
photo: Media;
|
||||||
|
choix: Entree[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le bassin ne contient que les espèces disposant d'une photographie de filière A,
|
||||||
|
* validée par un humain nommé et portant l'usage « épreuve ». C'est la même règle
|
||||||
|
* que `tools/data/validate.mjs`, qui fait échouer le build si elle est violée.
|
||||||
|
*
|
||||||
|
* Aujourd'hui ce bassin est vide, et c'est le comportement correct : personne n'a
|
||||||
|
* encore validé de photo. Le mode ne triche pas avec une image générée.
|
||||||
|
*/
|
||||||
|
const jouables = entreesJouables();
|
||||||
|
|
||||||
|
function fabriquer(): Question[] {
|
||||||
|
return ordreRevision(jouables.map((e) => e.id))
|
||||||
|
.slice(0, QUESTIONS)
|
||||||
|
.map((id) => {
|
||||||
|
const cible = jouables.find((e) => e.id === id) as Entree;
|
||||||
|
const photos = photosValidees(cible).filter((m) => m.usages.includes('epreuve'));
|
||||||
|
return {
|
||||||
|
cible,
|
||||||
|
photo: melanger(photos)[0],
|
||||||
|
choix: propositions(cible, jouables)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let questions = $state<Question[]>(jouables.length >= 4 ? fabriquer() : []);
|
||||||
|
let index = $state(0);
|
||||||
|
let reponse = $state<Entree | null>(null);
|
||||||
|
let score = $state(0);
|
||||||
|
let bonnes = $state(0);
|
||||||
|
|
||||||
|
const q = $derived(questions[index]);
|
||||||
|
const fini = $derived(questions.length > 0 && index >= questions.length);
|
||||||
|
const correct = $derived(reponse?.id === q?.cible.id);
|
||||||
|
|
||||||
|
function choisir(e: Entree) {
|
||||||
|
if (reponse || !q) return;
|
||||||
|
reponse = e;
|
||||||
|
const juste = e.id === q.cible.id;
|
||||||
|
if (juste) {
|
||||||
|
bonnes += 1;
|
||||||
|
score += POINTS;
|
||||||
|
}
|
||||||
|
repondre(q.cible.id, juste, juste ? POINTS : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function suivant() {
|
||||||
|
reponse = null;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejouer() {
|
||||||
|
questions = fabriquer();
|
||||||
|
index = 0;
|
||||||
|
reponse = null;
|
||||||
|
score = 0;
|
||||||
|
bonnes = 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.rekonet')} />
|
||||||
|
|
||||||
|
<div class="container section jeu">
|
||||||
|
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
|
||||||
|
|
||||||
|
{#if questions.length === 0}
|
||||||
|
<!-- État designé, pas page d'erreur : le manque est une information (ADR 0006). -->
|
||||||
|
<section class="bloque" aria-labelledby="bloque-titre">
|
||||||
|
<span class="flag-chip" aria-hidden="true"></span>
|
||||||
|
<h1 id="bloque-titre">{t(locale, 'jeu.indisponible.titre')}</h1>
|
||||||
|
<p>{t(locale, 'jeu.indisponible.texte')}</p>
|
||||||
|
<p class="chiffre">
|
||||||
|
<strong>{jouables.length}</strong> espèce{jouables.length > 1 ? 's' : ''} sur {FRUITS.length}
|
||||||
|
dispose d’une photographie validée. Il en faut au moins quatre pour poser une question
|
||||||
|
honnête.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
Ce mode fonctionne uniquement sur des photographies réelles, validées par un humain. Il ne
|
||||||
|
se rabattra jamais sur une image générée : ce serait apprendre à reconnaître ce qu’un
|
||||||
|
modèle invente.
|
||||||
|
</p>
|
||||||
|
<p class="actions">
|
||||||
|
<a class="btn" href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
|
||||||
|
<a class="btn btn--sobre" href={chemin(locale, 'je', 'kache')}>{t(locale, 'jeu.kache')}</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{:else if fini}
|
||||||
|
<section class="bilan" aria-labelledby="bilan-titre">
|
||||||
|
<h1 id="bilan-titre">{t(locale, 'jeu.termine')}</h1>
|
||||||
|
<p class="gros">
|
||||||
|
<span use:compte={score} aria-hidden="true">0</span>
|
||||||
|
<span class="sr-only">{score}</span>
|
||||||
|
</p>
|
||||||
|
<p>{t(locale, 'jeu.resultat', { bons: bonnes, total: questions.length })}</p>
|
||||||
|
<p class="actions">
|
||||||
|
<button class="btn" onclick={rejouer}>{t(locale, 'jeu.rejouer')}</button>
|
||||||
|
<a class="btn" href={chemin(locale, 'je', 'lakou')}>{t(locale, 'jeu.album')}</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{:else if q}
|
||||||
|
<header class="entete">
|
||||||
|
<h1 lang="gcf">{t(locale, 'jeu.rekonet')}</h1>
|
||||||
|
<p class="muted">{index + 1} / {questions.length} — {t(locale, 'jeu.score')} : {score}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<figure class="photo">
|
||||||
|
<img src={q.photo.fichier_local ?? ''} alt={t(locale, 'jeu.question')} />
|
||||||
|
<figcaption class="muted petit">
|
||||||
|
{q.photo.auteur} — {q.photo.licence}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<section aria-labelledby="question-titre">
|
||||||
|
<h2 id="question-titre">{t(locale, 'jeu.question')}</h2>
|
||||||
|
<ul class="choix">
|
||||||
|
{#each q.choix as choix (choix.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="btn reponse"
|
||||||
|
class:juste={reponse && choix.id === q.cible.id}
|
||||||
|
class:faux={reponse === choix && choix.id !== q.cible.id}
|
||||||
|
disabled={!!reponse}
|
||||||
|
onclick={() => choisir(choix)}
|
||||||
|
>
|
||||||
|
<span lang="gcf">{choix.noms.gcf}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="revelation" aria-live="polite">
|
||||||
|
{#if reponse}
|
||||||
|
<p class="verdict" class:ok={correct}>
|
||||||
|
{correct ? t(locale, 'jeu.bonne-reponse') : t(locale, 'jeu.mauvaise-reponse')}
|
||||||
|
</p>
|
||||||
|
<p>{t(locale, 'jeu.reponse-etait', { nom: q.cible.noms.gcf })}</p>
|
||||||
|
<p class="actions">
|
||||||
|
<button class="btn" onclick={suivant}>{t(locale, 'jeu.suivant')}</button>
|
||||||
|
<a href={chemin(locale, 'zerbaj', q.cible.id)}>{t(locale, 'zerbaj.voir')}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.jeu {
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fil {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bloque {
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-left: 4px solid var(--or-oki);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chiffre strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: var(--border-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choix {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reponse {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.reponse.juste {
|
||||||
|
border-color: var(--vert-oki);
|
||||||
|
color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
.reponse.juste::after {
|
||||||
|
background: var(--vert-oki);
|
||||||
|
}
|
||||||
|
.reponse.faux {
|
||||||
|
border-color: var(--rouge-oki);
|
||||||
|
color: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
.reponse.faux::after {
|
||||||
|
background: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
.reponse[disabled] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revelation {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
min-height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--rouge-oki);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.verdict.ok {
|
||||||
|
color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--sobre {
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gros {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(3rem, 12vw, 6rem);
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petit {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import { chemin, t } from '$lib/i18n';
|
||||||
|
import { FRUITS, MOIS } from '$lib/data';
|
||||||
|
import { melanger } from '$lib/jeu/tirage';
|
||||||
|
import { repondre } from '$lib/jeu/progression.svelte';
|
||||||
|
import { compte } from '$lib/motion/countup';
|
||||||
|
import type { Entree } from '$lib/data/types';
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
const locale = $derived(data.locale);
|
||||||
|
|
||||||
|
const QUESTIONS = 6;
|
||||||
|
const POINTS = 20;
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
mois: number;
|
||||||
|
cible: Entree;
|
||||||
|
choix: Entree[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const avecSaison = FRUITS.filter((e) => (e.saison_mois?.length ?? 0) > 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On tire un mois, puis un fruit qu'on y trouve, et trois fruits qu'on n'y trouve pas.
|
||||||
|
* Une question n'est posée que si les quatre existent : sinon elle enseignerait
|
||||||
|
* une opposition qui n'en est pas une.
|
||||||
|
*/
|
||||||
|
function fabriquer(): Question[] {
|
||||||
|
const sorties: Question[] = [];
|
||||||
|
for (const mois of melanger([...Array(12).keys()].map((i) => i + 1))) {
|
||||||
|
const dedans = melanger(avecSaison.filter((e) => e.saison_mois?.includes(mois)));
|
||||||
|
const dehors = melanger(avecSaison.filter((e) => !e.saison_mois?.includes(mois)));
|
||||||
|
if (!dedans.length || dehors.length < 3) continue;
|
||||||
|
sorties.push({ mois, cible: dedans[0], choix: melanger([dedans[0], ...dehors.slice(0, 3)]) });
|
||||||
|
if (sorties.length === QUESTIONS) break;
|
||||||
|
}
|
||||||
|
return sorties;
|
||||||
|
}
|
||||||
|
|
||||||
|
let questions = $state<Question[]>(fabriquer());
|
||||||
|
let index = $state(0);
|
||||||
|
let reponse = $state<Entree | null>(null);
|
||||||
|
let score = $state(0);
|
||||||
|
let bonnes = $state(0);
|
||||||
|
|
||||||
|
const q = $derived(questions[index]);
|
||||||
|
const fini = $derived(index >= questions.length);
|
||||||
|
const correct = $derived(reponse?.id === q?.cible.id);
|
||||||
|
|
||||||
|
function choisir(e: Entree) {
|
||||||
|
if (reponse || !q) return;
|
||||||
|
reponse = e;
|
||||||
|
const juste = e.id === q.cible.id;
|
||||||
|
if (juste) {
|
||||||
|
bonnes += 1;
|
||||||
|
score += POINTS;
|
||||||
|
}
|
||||||
|
repondre(q.cible.id, juste, juste ? POINTS : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function suivant() {
|
||||||
|
reponse = null;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejouer() {
|
||||||
|
questions = fabriquer();
|
||||||
|
index = 0;
|
||||||
|
reponse = null;
|
||||||
|
score = 0;
|
||||||
|
bonnes = 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.sezon')} />
|
||||||
|
|
||||||
|
<div class="container section jeu">
|
||||||
|
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
La saisonnalité du jeu de données n'est vérifiée contre aucune source et n'est pas
|
||||||
|
ventilée par île (docs/a-arbitrer.md §3). Le dire ici est la condition pour proposer
|
||||||
|
le mode : enseigner une saison fausse est pire que ne pas l'enseigner.
|
||||||
|
-->
|
||||||
|
<p class="avertissement">
|
||||||
|
{t(locale, 'fiche.saison-non-sourcee')}
|
||||||
|
<a href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if !questions.length}
|
||||||
|
<p>{t(locale, 'jeu.indisponible.texte')}</p>
|
||||||
|
{:else if fini}
|
||||||
|
<section class="bilan" aria-labelledby="bilan-titre">
|
||||||
|
<h1 id="bilan-titre">{t(locale, 'jeu.termine')}</h1>
|
||||||
|
<p class="gros">
|
||||||
|
<span use:compte={score} aria-hidden="true">0</span>
|
||||||
|
<span class="sr-only">{score}</span>
|
||||||
|
</p>
|
||||||
|
<p>{t(locale, 'jeu.resultat', { bons: bonnes, total: questions.length })}</p>
|
||||||
|
<p class="actions">
|
||||||
|
<button class="btn" onclick={rejouer}>{t(locale, 'jeu.rejouer')}</button>
|
||||||
|
<a class="btn" href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{:else if q}
|
||||||
|
<header class="entete">
|
||||||
|
<h1 lang="gcf">{t(locale, 'jeu.sezon')}</h1>
|
||||||
|
<p class="muted">{index + 1} / {questions.length} — {t(locale, 'jeu.score')} : {score}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-labelledby="mois-titre">
|
||||||
|
<h2 id="mois-titre" class="question">
|
||||||
|
Quel fruit trouve-t-on en <strong>{MOIS[q.mois - 1]}</strong> ?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ol class="calendrier" aria-hidden="true">
|
||||||
|
{#each MOIS as nom, i (nom)}
|
||||||
|
<li class:actif={i + 1 === q.mois}>{nom.slice(0, 3)}</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<ul class="choix">
|
||||||
|
{#each q.choix as choix (choix.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="btn reponse"
|
||||||
|
class:juste={reponse && choix.id === q.cible.id}
|
||||||
|
class:faux={reponse === choix && choix.id !== q.cible.id}
|
||||||
|
disabled={!!reponse}
|
||||||
|
onclick={() => choisir(choix)}
|
||||||
|
>
|
||||||
|
<span lang="gcf">{choix.noms.gcf}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="revelation" aria-live="polite">
|
||||||
|
{#if reponse}
|
||||||
|
<p class="verdict" class:ok={correct}>
|
||||||
|
{correct ? t(locale, 'jeu.bonne-reponse') : t(locale, 'jeu.mauvaise-reponse')}
|
||||||
|
</p>
|
||||||
|
<p>{t(locale, 'jeu.reponse-etait', { nom: q.cible.noms.gcf })}</p>
|
||||||
|
<p class="muted petit">
|
||||||
|
{q.cible.noms.gcf} : {q.cible.saison_mois?.map((m) => MOIS[m - 1]).join(', ')}
|
||||||
|
</p>
|
||||||
|
<p class="actions">
|
||||||
|
<button class="btn" onclick={suivant}>{t(locale, 'jeu.suivant')}</button>
|
||||||
|
<a href={chemin(locale, 'zerbaj', q.cible.id)}>{t(locale, 'zerbaj.voir')}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.jeu {
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fil {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avertissement {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-left: 4px solid var(--or-oki);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.question {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.question strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendrier {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendrier li {
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendrier li.actif {
|
||||||
|
background: var(--or-oki);
|
||||||
|
border-color: var(--or-oki);
|
||||||
|
color: var(--noir-oki);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choix {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reponse {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.reponse.juste {
|
||||||
|
border-color: var(--vert-oki);
|
||||||
|
color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
.reponse.juste::after {
|
||||||
|
background: var(--vert-oki);
|
||||||
|
}
|
||||||
|
.reponse.faux {
|
||||||
|
border-color: var(--rouge-oki);
|
||||||
|
color: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
.reponse.faux::after {
|
||||||
|
background: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
.reponse[disabled] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revelation {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
min-height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--rouge-oki);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.verdict.ok {
|
||||||
|
color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gros {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(3rem, 12vw, 6rem);
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petit {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user