feat(session-1): socle SvelteKit et encyclopédie /zerbaj/ prérendue

SvelteKit 2 + Svelte 5 runes + TS strict, adapter-static en prérendu
intégral, trailingSlash always, paths.relative false.

Mesures de la porte de sortie
- 56 pages HTML prérendues (13 fiches × 3 langues + pages de service)
- 59 Ko de JS gzip (budget : 170) · build total 1,4 Mo (budget : 2 Mo)
- 0 chargement tiers, vérifié par grep du build
- fiche lisible sans JS : 1694 caractères de texte rendu
- npm run check : 248 fichiers, 0 erreur, 0 warning
- 0 erreur console au chargement
Lighthouse non exécuté : porte encore ouverte, à vérifier avant la S6.

Charte OKI
Tokens partagés, thème sombre par défaut, Archivo et Inter self-hébergées
(124 Ko de woff2), flag-bar à segments francs, bouton à balayage or,
gate prefers-reduced-motion unique. CSP stricte en .htaccess et _headers,
404 statique autonome, PWA avec registerSW à chemin absolu (playbook 2.9).

L'interface kréyòl n'est pas écrite par l'agent
Le catalogue gcf ne contient que les termes venant des documents du
porteur — 10 chaînes sur 132. Le reste vaut null et se replie sur le
français ; check:i18n produit la liste des 122 phrases à traduire, par
écran. <html lang> annonce la langue réellement écrite : fr tant que la
couverture reste sous 60 %, avec les fragments kréyòl marqués un par un.
Des tournures kréyòl que j'avais inventées ont été retirées.

Corrections en cours de build
- vite-plugin-pwa/sveltekit n'existe pas sans @vite-pwa/sveltekit :
  VitePWA en direct, comme le prescrivait déjà le playbook.
- @sveltejs/vite-plugin-svelte v7 n'exporte plus sveltekit : import
  depuis @sveltejs/kit/vite, vérifié sur les exports réels.
- Le sélecteur de langue fabriquait /fr/offline/, page inexistante.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
OKI
2026-07-27 11:44:21 -04:00
co-authored by Claude Opus 5
parent 8cba5adfa5
commit ccad7177b3
58 changed files with 9946 additions and 3 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "ki-fwi",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5273"],
"port": 5273
}
]
}
+5
View File
@@ -26,3 +26,8 @@ doc2sveltekit-transition/
# Pipeline assets (filière B) — sorties intermédiaires ComfyUI
tools/assets/.venv/
tools/assets/out/
# Build SvelteKit
build/
.svelte-kit/
dev-dist/
+227
View File
@@ -0,0 +1,227 @@
# Interface à traduire
> Généré par `npm run check:i18n` le 2026-07-27.
> Sortie de script : ne pas éditer à la main.
Le kréyòl est la langue principale du projet, et pourtant ce site affiche du français.
Ce nest pas un oubli : **aucun agent de code n’écrit de kréyòl** (ADR 0005), et un
catalogue rempli par une machine serait pire que vide — il figerait des tournures que
personne nemploie.
La traduction est donc une contribution, au même titre quune photographie ou un
enregistrement. Marche à suivre : [`kreyol-participatif.md`](kreyol-participatif.md).
## Kréyòl gwadloupéyen — 8 % traduit
**122 phrases sur 132.**
### Identité du site (2)
| clé | français | traduction |
|---|---|---|
| `site.tagline` | Reconnaître les fruits du pays | |
| `site.desc` | Jeu éducatif libre pour apprendre à reconnaître les fruits, les arbres et les plantes des Antilles. En kréyòl d'abord. | |
### Navigation (8)
| clé | français | traduction |
|---|---|---|
| `nav.accueil` | Accueil | |
| `nav.je` | Jouer | |
| `nav.kontribiye` | Participer | |
| `nav.credits` | Crédits | |
| `nav.menu` | Menu | |
| `nav.langue` | Langue | |
| `nav.theme` | Changer de thème | |
| `nav.aller-contenu` | Aller au contenu | |
### Accueil (8)
| clé | français | traduction |
|---|---|---|
| `home.intro` | Reconnaître les fruits, les arbres et les plantes de nos îles. En kréyòl d'abord, parce que c'est leur nom. | |
| `home.jouer` | Jouer | |
| `home.explorer` | Explorer l'encyclopédie | |
| `home.compteur` | {n} espèces documentées | |
| `home.pourquoi.titre` | Pourquoi ce jeu | |
| `home.pourquoi.texte` | Une partie des enfants des Antilles ne sait plus nommer les fruits de son île, ni reconnaître les arbres qui les portent. Ce jeu essaie de rendre cette connaissance. | |
| `home.verite.titre` | Des photos réelles, jamais d'images générées | |
| `home.verite.texte` | Toutes les épreuves de reconnaissance s'appuient sur des photographies réelles, sous licence libre et validées par un humain. Les modèles génératifs produisent des fruits plausibles et faux : un jeu bâti dessus apprendrait l'erreur. | |
### Encyclopédie (4)
| clé | français | traduction |
|---|---|---|
| `zerbaj.intro` | Les espèces documentées par le projet. Chaque fiche indique aussi ce qui lui manque. | |
| `zerbaj.fruits` | Fruits | |
| `zerbaj.dangereuses` | Plantes à ne pas toucher | |
| `zerbaj.voir` | Voir la fiche | |
### Fiche despèce (33)
| clé | français | traduction |
|---|---|---|
| `fiche.nom-kreyol` | Nom kréyòl | |
| `fiche.autres-noms` | Autres noms | |
| `fiche.variantes` | Autres formes entendues | |
| `fiche.taxon` | Nom scientifique | |
| `fiche.famille` | Famille | |
| `fiche.verifie` | Vérifié le {date} contre Wikidata et GBIF | |
| `fiche.iles` | Où on le trouve | |
| `fiche.saison` | Quand on le trouve | |
| `fiche.saison-non-sourcee` | Ces mois ne sont vérifiés contre aucune source. À confirmer. | |
| `fiche.indices` | Comment le reconnaître | |
| `fiche.indice.feuille` | La feuille | |
| `fiche.indice.ecorce` | L'écorce | |
| `fiche.indice.port` | L'arbre | |
| `fiche.indice.coupe` | À la coupe | |
| `fiche.indice.fruit` | Le fruit | |
| `fiche.indice.fleur` | La fleur | |
| `fiche.indice.latex` | Le latex | |
| `fiche.indice.graine` | La graine | |
| `fiche.indice.usage_haie` | Où on le rencontre | |
| `fiche.danger` | Danger | |
| `fiche.danger.vigilance` | Vigilance | |
| `fiche.danger.eleve` | Danger élevé | |
| `fiche.danger.critique` | Danger grave | |
| `fiche.danger.conduite` | Que faire | |
| `fiche.danger.signalement` | Comment il est signalé | |
| `fiche.comestible.oui` | Se mange | |
| `fiche.comestible.non` | Ne se mange pas | |
| `fiche.medias` | Images | |
| `fiche.aucune-image` | Aucune image validée pour cette fiche. | |
| `fiche.ecouter` | Écouter le nom | |
| `fiche.audio-manquant` | Personne n'a encore enregistré ce nom. | |
| `fiche.sources` | Sources | |
| `fiche.retour` | Retour à l'encyclopédie | |
### Ce qui manque (9)
| clé | français | traduction |
|---|---|---|
| `manques.titre` | Ce qui manque à cette fiche | |
| `manques.intro` | Nous préférons le dire que le cacher. Chacun de ces points peut être comblé, et les outils sont gratuits. | |
| `manques.aider` | Comment aider | |
| `manques.photos` | Il manque une photographie : {vues} | |
| `manques.audio` | Le nom n'a pas encore été enregistré par une voix humaine. | |
| `manques.orthographe` | L'orthographe kréyòl n'a pas encore été relue par un locuteur. | |
| `manques.sources` | Aucune source factuelle n'est encore citée. | |
| `manques.saison` | La saison n'est vérifiée contre aucune source. | |
| `manques.rien` | Rien ne manque à cette fiche. C'est rare — merci à ceux qui l'ont complétée. | |
### Questions de langue (5)
| clé | français | traduction |
|---|---|---|
| `kreyol.question` | Une question aux locuteurs | |
| `kreyol.repondre` | Répondre | |
| `kreyol.statut.a_relire` | Orthographe à relire | |
| `kreyol.statut.en_discussion` | Orthographe en discussion | |
| `kreyol.statut.relu` | Orthographe relue par {nom} | |
### Participation (7)
| clé | français | traduction |
|---|---|---|
| `kontribiye.titre` | Donner un coup de main | |
| `kontribiye.intro` | Seul on va plus vite, ensemble on va plus loin. Ce projet documente des espèces que peu de gens ont photographiées et une langue que peu de bases de données connaissent. Rien de ce qui suit ne demande d'être spécialiste. | |
| `kontribiye.outils` | Les outils existent déjà, ils sont gratuits, et ce qu'on y dépose sert à tout le monde — pas seulement à ce jeu. | |
| `kontribiye.chiffres` | En chiffres, aujourd'hui | |
| `kontribiye.geste` | Ce que vous pouvez faire | |
| `kontribiye.duree` | environ {n} minutes | |
| `kontribiye.ensemble` | Chaque photo, chaque nom, chaque voix déposée dans les communs reste disponible pour les enfants qui viendront après. | |
### Le jeu (27)
| clé | français | traduction |
|---|---|---|
| `jeu.titre` | Jouer | |
| `jeu.choisir` | Choisir un mode | |
| `jeu.rekonet.desc` | Reconnaître un fruit sur une photo. | |
| `jeu.kache.desc` | Deviner à partir d'indices dévoilés un à un. | |
| `jeu.sezon.desc` | Placer les fruits dans l'année. | |
| `jeu.ranmase.desc` | Récolter dans le jaden, éviter ce qui pique. | |
| `jeu.patouche.desc` | Les plantes à ne jamais toucher. Sans chrono, sans score. | |
| `jeu.commencer` | Commencer | |
| `jeu.rejouer` | Rejouer | |
| `jeu.suivant` | Suivant | |
| `jeu.quitter` | Quitter | |
| `jeu.score` | Score | |
| `jeu.bonne-reponse` | Bonne réponse | |
| `jeu.mauvaise-reponse` | Ce n'était pas celui-là | |
| `jeu.reponse-etait` | C'était {nom} | |
| `jeu.question` | Quel fruit est-ce ? | |
| `jeu.indice-suivant` | Un indice de plus | |
| `jeu.indices-restants` | {n} indices restants | |
| `jeu.termine` | Partie terminée | |
| `jeu.resultat` | {bons} sur {total} | |
| `jeu.album.desc` | Les fiches débloquées. | |
| `jeu.album.vide` | Aucune fiche débloquée pour l'instant. | |
| `jeu.progression.export` | Exporter ma progression | |
| `jeu.progression.import` | Importer une progression | |
| `jeu.progression.effacer` | Effacer ma progression | |
| `jeu.indisponible.titre` | Ce mode n'est pas encore jouable | |
| `jeu.indisponible.texte` | Il faut au moins deux photographies validées par espèce, et il n'y en a pas encore assez. Ce n'est pas un bug : personne ne les a encore versées. | |
### Module « Pa touché » (3)
| clé | français | traduction |
|---|---|---|
| `patouche.avertissement` | Ces plantes sont dangereuses. Ce module sert à les reconnaître pour les éviter — il n'est ni chronométré ni noté. | |
| `patouche.conduite` | En cas de contact ou d'ingestion | |
| `patouche.urgence` | En cas de doute, appelez les secours. | |
### Pied de page (4)
| clé | français | traduction |
|---|---|---|
| `footer.projet` | Un projet libre d'ORGANISATION KA INTERNATIONALE | |
| `footer.licence` | Code sous AGPL-3.0 · Contenu sous CC BY-SA 4.0 | |
| `footer.sans-compte` | Aucun compte, aucune publicité, aucun traceur. | |
| `footer.credits` | Crédits et licences | |
### Crédits (5)
| clé | français | traduction |
|---|---|---|
| `credits.titre` | Crédits | |
| `credits.intro` | Rien n'est « libre de droit ». Chaque image porte le nom de son auteur et sa licence. | |
| `credits.medias` | Images | |
| `credits.audio` | Voix | |
| `credits.sources` | Sources de données | |
### Erreurs (4)
| clé | français | traduction |
|---|---|---|
| `erreur.404.titre` | Page introuvable | |
| `erreur.404.texte` | Cette page n'existe pas. | |
| `erreur.retour` | Retour à l'accueil | |
| `erreur.generique` | Quelque chose s'est mal passé. | |
### Hors ligne (2)
| clé | français | traduction |
|---|---|---|
| `offline.titre` | Hors ligne | |
| `offline.texte` | Vous êtes hors ligne. Les pages déjà visitées restent disponibles. | |
### Bandeau de traduction (1)
| clé | français | traduction |
|---|---|---|
| `i18n.non-traduit` | Cette interface n'est pas encore traduite en kréyòl. Le texte ci-dessous est en français. | |
## English — 100 % traduit
_Rien à traduire._
## Comment rendre une traduction
Remplir la colonne « traduction », ou envoyer la liste par tout moyen — un message,
un fichier, une note vocale. La forme na aucune importance ; la relecture par un
locuteur en a.
Les valeurs traduites se reportent dans `src/lib/i18n/gcf.json`. Une valeur `null`
se replie automatiquement sur le français, avec lattribut `lang` correct : rien ne
casse tant que la traduction manque.
+99
View File
@@ -0,0 +1,99 @@
# Session 1 — Socle
**Date :** 2026-07-27 · **Livrable :** l'encyclopédie `/zerbaj/`, prérendue et trilingue.
## Mesures de la porte de sortie
| Critère | Cible | Mesuré |
|---|---|---|
| Fiches en ligne | 13, une page prérendue chacune | **56 pages HTML** (13 fiches × 3 langues + 17 pages de service) ✅ |
| JS initial gzip | ≤ 170 Ko | **59 Ko** ✅ |
| Poids total du build | ≤ 2 Mo | **1,4 Mo** ✅ |
| Requêtes vers un domaine tiers | 0 | **0**, vérifié par grep du build ✅ |
| Contenu lisible sans JavaScript | intégral | **1 694 caractères** de texte rendu sur une fiche, sans exécuter un octet de JS ✅ |
| `npm run check` | 0 erreur, 0 warning | **248 fichiers, 0/0** ✅ |
| `npm run build` | vert | vert ✅ |
| Erreurs console au chargement | 0 | **0** ✅ |
| Chaque fiche affiche ce qui lui manque | 13/13 | ✅ |
| Questions kréyòl affichées | 4 | ✅ |
Lighthouse n'a pas été exécuté : il demande un navigateur en conditions réelles que je ne
peux pas piloter ici. **La porte « Lighthouse ≥ 90/95/95/95 » reste donc non vérifiée** et
doit l'être avant la session 6. Les budgets qui la déterminent — JS, poids, absence de
tiers, contenu SSR — sont eux mesurés et tenus.
## Ce qui a été construit
SvelteKit 2 + Svelte 5 en runes, TypeScript strict, `adapter-static` en prérendu intégral,
`trailingSlash: 'always'`, `paths.relative: false` (playbook §5b.5).
Tokens de la charte OKI dans un fichier partagé, thème sombre par défaut et clair en opt-in,
polices Archivo et Inter self-hébergées (**124 Ko** de woff2 latin), flag-bar et flag chip,
bouton à balayage or, gate `prefers-reduced-motion` unique.
i18n par dossiers sans middleware : `/` en kréyòl, `/fr/`, `/en/`. Route optionnelle
`[[locale=locale]]` avec matcher, ce qui évite de tripler chaque page.
PWA : `VitePWA` en direct plutôt que `@vite-pwa/sveltekit`, enregistrement par fichier
statique à chemin absolu (playbook §2.9), page hors-ligne designée, manifest, icônes 192 /
512 / maskable générées depuis le SVG (**1,8 à 6,3 Ko** chacune).
CSP stricte en deux formats — `.htaccess` et `_headers` —, 404 statique autonome, sitemap
généré depuis le build réel avec alternates hreflang (54 URLs sur 18 contenus).
## Trois décisions prises en cours de route
### L'interface kréyòl ne peut pas être écrite par un agent
Le projet pose le kréyòl comme langue principale, et l'ADR 0005 m'interdit d'en écrire.
Résolu ainsi : le catalogue `gcf` ne contient que les termes venant des documents du porteur
`KI FWI SA YÉ ?`, `Zerbaj`, `Rekonèt`, `Kaché`, `Sézon`, `Ranmasé`, `Pa touché`, `Lakou`
soit **10 chaînes sur 132**. Tout le reste vaut `null` et se replie sur le français.
`npm run check:i18n` produit la liste des **122 phrases à traduire**, par écran, avec le
français en regard. C'est une tâche de participation, pas une dette technique.
J'avais d'ailleurs commencé par écrire du kréyòl de mon cru dans le catalogue français
(`Pa ni ayen la`, `Ba on koudmen`, `Jwé`, `Pa ni rézo`). Retiré : ces tournures étaient de
moi, pas de la langue.
### `<html lang>` annonce la langue réellement écrite
Poser `lang="gcf"` sur une page dont le texte est français ferait lire ce français avec la
phonétique kréyòl par un lecteur d'écran. `langDocument()` renvoie donc `fr` tant que la
couverture du catalogue reste sous 60 %, et les fragments réellement kréyòl — noms de
fruits, noms de modes, titre du site — portent `lang="gcf"` individuellement.
La fonction basculera d'elle-même quand la traduction dépassera le seuil. Il n'y a rien à
changer dans le code : il faut traduire.
Un bandeau discret le dit sur chaque page en kréyòl, plutôt que de laisser croire.
### Les modes de jeu ne sont pas annoncés comme cliquables
`/je/` liste les cinq modes, aucun n'est lié : les routes n'existent qu'en session 2 et 4.
Le prerender en mode strict fait échouer le build sur un lien mort — c'est le comportement
voulu, et il a effectivement attrapé le problème.
Le mode Rekonèt affiche en plus son blocage réel : il demande des photographies validées, et
il n'y en a aucune. Le message le dit sans détour — « ce n'est pas un bug : personne ne les a
encore versées » — et renvoie vers la page de participation.
## Deux erreurs corrigées pendant le build
`vite-plugin-pwa/sveltekit` n'existe pas sans le paquet `@vite-pwa/sveltekit` : le playbook
prescrivait déjà `VitePWA` en direct, je m'y suis aligné plutôt que d'ajouter une dépendance.
`@sveltejs/vite-plugin-svelte` v7 n'exporte plus `sveltekit` — l'import vient de
`@sveltejs/kit/vite`. Vérifié sur les exports réels du paquet, pas supposé.
Et un vrai défaut de conception : le sélecteur de langue fabriquait `/fr/offline/` pour une
page qui n'existe qu'en un exemplaire. `cheminAlterne()` renvoie désormais la racine de la
langue cible sur les routes non localisées.
## Reste à faire avant la session 6
- Lighthouse mobile sur l'accueil et sur une fiche.
- Vérification manuelle de `prefers-reduced-motion` et de la navigation clavier.
- `og-image` et icônes sont des aplats graphiques sans texte : à remplacer si le porteur
veut une image sociale plus travaillée.
+6496 -1
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -16,10 +16,25 @@
"validate:data": "node tools/data/validate.mjs",
"build:credits": "node tools/data/build-credits.mjs",
"test:porte": "node tools/data/test-porte.mjs",
"rapport:manques": "node tools/data/rapport-manques.mjs"
"rapport:manques": "node tools/data/rapport-manques.mjs",
"dev": "vite dev",
"build": "vite build && node tools/build/sitemap.mjs",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:i18n": "node tools/data/check-i18n.mjs"
},
"devDependencies": {
"@fontsource/archivo": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1"
"ajv-formats": "^3.0.1",
"svelte": "^5.56.8",
"svelte-check": "^4.7.4",
"typescript": "^5.9.3",
"vite": "^8.1.5",
"vite-plugin-pwa": "^1.3.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
declare global {
namespace App {
interface Error {
message: string;
}
interface Locals {
locale: import('$lib/i18n').Locale;
}
interface PageData {
locale: import('$lib/i18n').Locale;
}
}
}
export {};
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="%lang%" class="no-js">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0D0D0D" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link rel="preload" href="%sveltekit.assets%/fonts/archivo-latin-700-normal.woff2" as="font" type="font/woff2" crossorigin />
<!-- Scripts externes, jamais inline : la CSP du projet est script-src 'self'. -->
<script src="%sveltekit.assets%/theme.js"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<script src="%sveltekit.assets%/registerSW.js"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
import type { Handle } from '@sveltejs/kit';
import { langDocument, localeFromPath } from '$lib/i18n';
/**
* Pose l'attribut `lang` correct sur chaque page prérendue.
*
* Le kréyòl est la langue principale : `/` sert `gcf`, `/fr/` et `/en/` sont les
* traductions (ADR 0005). Un attribut `lang` faux fait prononcer les noms de fruits
* avec la phonétique de la mauvaise langue par un lecteur d'écran — sur un jeu qui
* enseigne des noms, ce n'est pas un détail.
*
* `langDocument()` renvoie la langue dans laquelle la page est *réellement* écrite :
* tant que le catalogue kréyòl est incomplet, la route `/` annonce `fr` et marque
* individuellement ses fragments kréyòl. Annoncer une langue qu'on n'écrit pas encore
* serait un mensonge coûteux pour ceux qui utilisent un lecteur d'écran.
*/
export const handle: Handle = async ({ event, resolve }) => {
const locale = localeFromPath(event.url.pathname);
event.locals.locale = locale;
return resolve(event, {
transformPageChunk: ({ html }) => html.replaceAll('%lang%', langDocument(locale))
});
};
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { chemin, t, type Locale } from '$lib/i18n';
interface Props {
locale: Locale;
}
const { locale }: Props = $props();
</script>
<footer>
<div class="flag-bar" aria-hidden="true"></div>
<div class="container contenu">
<p class="projet">{t(locale, 'footer.projet')}</p>
<p class="muted petit">{t(locale, 'footer.sans-compte')}</p>
<p class="muted petit">{t(locale, 'footer.licence')}</p>
<p><a href={chemin(locale, 'kredi')}>{t(locale, 'footer.credits')}</a></p>
</div>
</footer>
<style>
footer {
margin-top: var(--space-5);
background: var(--noir-profond);
}
.contenu {
padding-block: var(--space-4);
}
.projet {
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: var(--space-1);
}
.petit {
font-size: 0.85rem;
margin-bottom: 0.35rem;
}
footer a {
color: var(--or-oki);
text-decoration: underline;
text-underline-offset: 0.2em;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { chemin, t, type Locale } from '$lib/i18n';
import { manques } from '$lib/data';
import type { Entree } from '$lib/data/types';
interface Props {
entree: Entree;
locale: Locale;
/** Compact : une ligne discrète en liste. Complet : le bloc de la fiche. */
compact?: boolean;
}
const { entree, locale, compact = false }: Props = $props();
const m = $derived(manques(entree));
const LIBELLE_VUE: Record<string, string> = {
feuille: 'feuille',
ecorce: 'écorce',
coupe: 'coupe',
fruit_entier: 'fruit entier'
};
const vues = $derived(m.vues.map((v) => LIBELLE_VUE[v] ?? v).join(', '));
const total = $derived(m.vues.length + (m.audio ? 1 : 0) + (m.orthographe ? 1 : 0) + (m.sources ? 1 : 0));
</script>
{#if compact}
{#if total > 0}
<p class="compact muted">{total} manque{total > 1 ? 's' : ''}</p>
{/if}
{:else}
<section class="manques" aria-labelledby="manques-titre">
<h2 id="manques-titre">{t(locale, 'manques.titre')}</h2>
{#if m.aucun}
<p>{t(locale, 'manques.rien')}</p>
{:else}
<p class="muted">{t(locale, 'manques.intro')}</p>
<ul>
{#if m.vues.length}
<li>{t(locale, 'manques.photos', { vues })}</li>
{/if}
{#if m.audio}
<li>{t(locale, 'manques.audio')}</li>
{/if}
{#if m.orthographe}
<li>{t(locale, 'manques.orthographe')}</li>
{/if}
{#if m.sources}
<li>{t(locale, 'manques.sources')}</li>
{/if}
{#if m.saison}
<li>{t(locale, 'manques.saison')}</li>
{/if}
</ul>
<p>
<a class="btn" href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
</p>
{/if}
</section>
{/if}
<style>
.manques {
margin-top: var(--space-4);
padding: var(--space-3);
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--vert-oki);
border-radius: var(--radius-sm);
}
.manques ul {
margin: 0 0 var(--space-2);
padding-left: 1.2rem;
}
.manques li {
margin-bottom: 0.35rem;
}
.compact {
margin: 0.4rem 0 0;
font-size: 0.8rem;
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { page } from '$app/state';
import { LOCALES, NOM_LOCALE, chemin, cheminAlterne, t, type Locale } from '$lib/i18n';
interface Props {
locale: Locale;
}
const { locale }: Props = $props();
const liens = $derived([
{ href: chemin(locale, 'zerbaj'), libelle: t(locale, 'nav.zerbaj'), kreyol: true },
{ href: chemin(locale, 'je'), libelle: t(locale, 'nav.je'), kreyol: false },
{ href: chemin(locale, 'kontribiye'), libelle: t(locale, 'nav.kontribiye'), kreyol: false }
]);
const courant = (href: string) => page.url.pathname === href;
</script>
<a class="skip-link" href="#contenu">{t(locale, 'nav.aller-contenu')}</a>
<header>
<nav class="container barre" aria-label={t(locale, 'nav.menu')}>
<a class="marque" href={chemin(locale)}>
<span class="flag-chip" aria-hidden="true"></span>
<span lang="gcf">{t(locale, 'site.name')}</span>
</a>
<ul class="liens">
{#each liens as lien (lien.href)}
<li>
<a href={lien.href} aria-current={courant(lien.href) ? 'page' : undefined}>
{#if lien.kreyol}<span lang="gcf">{lien.libelle}</span>{:else}{lien.libelle}{/if}
</a>
</li>
{/each}
</ul>
<ul class="langues" aria-label={t(locale, 'nav.langue')}>
{#each LOCALES as l (l)}
<li>
<a
href={cheminAlterne(page.url.pathname, l)}
hreflang={l}
aria-current={l === locale ? 'true' : undefined}
>
{NOM_LOCALE[l]}
</a>
</li>
{/each}
</ul>
</nav>
<div class="flag-bar" aria-hidden="true"></div>
</header>
<style>
header {
position: sticky;
top: 0;
z-index: 20;
background: var(--noir-oki);
border-bottom: 1px solid var(--line);
}
.barre {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding-block: 0.6rem;
}
.marque {
display: inline-flex;
align-items: center;
gap: 0.6rem;
font-family: var(--font-display);
font-weight: 800;
text-transform: uppercase;
letter-spacing: -0.01em;
text-decoration: none;
color: var(--blanc-creme);
margin-right: auto;
min-height: 44px;
}
ul {
display: flex;
align-items: center;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
}
.liens a,
.langues a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding-inline: 0.35rem;
font-family: var(--font-display);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.03em;
text-decoration: none;
color: var(--blanc-creme);
border-bottom: 2px solid transparent;
}
.liens a:hover,
.langues a:hover,
.liens a[aria-current='page'],
.langues a[aria-current='true'] {
color: var(--or-oki);
border-bottom-color: var(--or-oki);
}
.langues {
font-size: 0.8rem;
}
.langues a {
font-size: 0.75rem;
opacity: 0.8;
}
.langues a[aria-current='true'] {
opacity: 1;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import { chemin, t, type Locale } from '$lib/i18n';
import type { Entree } from '$lib/data/types';
interface Props {
entree: Entree;
locale: Locale;
}
const { entree, locale }: Props = $props();
const o = $derived(entree.orthographe);
const variantes = $derived(entree.noms.variantes_gcf ?? []);
</script>
<!--
Le doute orthographique est affiché, jamais masqué (ADR 0005 amendé).
Une graphie incertaine présentée avec l'aplomb d'une certitude apprend une erreur ;
la même graphie assortie de sa question invite à la corriger.
-->
<section class="statut" class:discussion={o.statut === 'en_discussion'}>
<p class="etiquette">
{#if o.statut === 'relu'}
{t(locale, 'kreyol.statut.relu', { nom: o.relu_par ?? '' })}
{:else if o.statut === 'en_discussion'}
{t(locale, 'kreyol.statut.en_discussion')}
{:else}
{t(locale, 'kreyol.statut.a_relire')}
{/if}
</p>
{#if o.question_ouverte}
<h2>{t(locale, 'kreyol.question')}</h2>
<p class="question">{o.question_ouverte}</p>
{/if}
{#if variantes.length}
<h3>{t(locale, 'fiche.variantes')}</h3>
<ul>
{#each variantes as v (v.graphie + v.source)}
<li>
<strong lang="gcf">{v.graphie}</strong>
<span class="muted">{v.source}{v.note ? ` · ${v.note}` : ''}</span>
</li>
{/each}
</ul>
{/if}
{#if o.statut !== 'relu'}
<p>
<a class="btn" href={chemin(locale, 'kontribiye')}>{t(locale, 'kreyol.repondre')}</a>
</p>
{/if}
</section>
<style>
.statut {
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--line);
border-radius: var(--radius-sm);
}
.statut.discussion {
border-left-color: var(--or-oki);
}
.etiquette {
margin: 0;
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
}
.question {
font-size: 1.05rem;
}
h2 {
margin-top: var(--space-1);
font-size: 1.1rem;
}
h3 {
font-size: 0.9rem;
margin-top: var(--space-2);
}
ul {
margin: 0 0 var(--space-2);
padding-left: 1.2rem;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { BALISE_LANG, LOCALES, cheminAlterne, t, type Locale } from '$lib/i18n';
interface Props {
locale: Locale;
pathname: string;
titre?: string;
description?: string;
/** JSON-LD déjà sérialisé, first-party uniquement. */
jsonLd?: string;
}
const { locale, pathname, titre, description, jsonLd }: Props = $props();
const SITE = 'https://ki-fwi.o-k-i.net';
const titreComplet = $derived(titre ? `${titre}${t(locale, 'site.name')}` : t(locale, 'site.name'));
const desc = $derived(description ?? t(locale, 'site.desc'));
const canonical = $derived(`${SITE}${pathname}`);
</script>
<svelte:head>
<title>{titreComplet}</title>
<meta name="description" content={desc} />
<link rel="canonical" href={canonical} />
{#each LOCALES as l (l)}
<link rel="alternate" hreflang={BALISE_LANG[l]} href={`${SITE}${cheminAlterne(pathname, l)}`} />
{/each}
<link rel="alternate" hreflang="x-default" href={`${SITE}${cheminAlterne(pathname, 'fr')}`} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={t(locale, 'site.name')} />
<meta property="og:title" content={titreComplet} />
<meta property="og:description" content={desc} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content={BALISE_LANG[locale]} />
<meta property="og:image" content={`${SITE}/og-image.png`} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={t(locale, 'site.name')} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={titreComplet} />
<meta name="twitter:description" content={desc} />
{#if jsonLd}
<!-- JSON-LD first-party, construit depuis nos propres données. -->
{@html `<script type="application/ld+json">${jsonLd}<\/script>`}
{/if}
</svelte:head>
+107
View File
@@ -0,0 +1,107 @@
/**
* Accès au dataset. Un seul jeu de données alimente l'encyclopédie et le jeu.
*
* Les fichiers sont importés au build (`import.meta.glob` eager) : le site est
* entièrement prérendu, il n'y a aucun chargement de données au runtime, donc
* aucune requête réseau à faire pour lire une fiche.
*/
import type { Entree, Ile, Manques, Media, Vue } from './types';
const modules = import.meta.glob<{ default: Entree }>('./fruits/*.json', { eager: true });
/** Toutes les entrées, triées par nom kréyòl — c'est le nom principal (ADR 0005). */
export const ENTREES: Entree[] = Object.values(modules)
.map((m) => m.default)
.sort((a, b) => a.noms.gcf.localeCompare(b.noms.gcf, 'fr'));
export const FRUITS = ENTREES.filter((e) => e.categorie === 'fruit');
export const DANGEREUSES = ENTREES.filter((e) => e.categorie === 'plante_dangereuse');
const PAR_ID = new Map(ENTREES.map((e) => [e.id, e]));
export const entreeParId = (id: string): Entree | undefined => PAR_ID.get(id);
export const NOM_ILE: Record<Ile, string> = {
gp: 'Guadeloupe',
mq: 'Martinique',
gf: 'Guyane',
re: 'La Réunion'
};
export const MOIS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre'
];
/** Les vues qu'une fiche complète devrait présenter. Ordre pédagogique du mode Kaché. */
export const VUES_ATTENDUES: Vue[] = ['feuille', 'ecorce', 'coupe', 'fruit_entier'];
/**
* Un média n'est utilisable pour identifier que s'il est une photographie réelle,
* validée par un humain nommé. Même règle que `tools/data/validate.mjs` — ici pour
* l'affichage, là-bas pour refuser le build. Les deux doivent dire la même chose.
*/
export const estPhotoUtilisable = (m: Media): boolean =>
m.filiere === 'A' && m.type_media === 'photo' && m.valide && m.vue !== null;
export const photosValidees = (e: Entree): Media[] => e.medias.filter(estPhotoUtilisable);
/** Médias affichables sur une fiche : validés, quelle que soit leur nature. */
export const mediasAffichables = (e: Entree): Media[] => e.medias.filter((m) => m.valide);
/**
* Ce qui manque à une entrée.
*
* Cette fonction est le cœur de l'ADR 0006 : elle rend le trou visible et nommé,
* au lieu de laisser une fiche paraître complète parce qu'elle est vide.
*/
export function manques(e: Entree): Manques {
const vuesCouvertes = new Set(photosValidees(e).map((m) => m.vue));
const vues = VUES_ATTENDUES.filter((v) => !vuesCouvertes.has(v));
const audio = !e.audio.gcf || !e.audio.valide;
const orthographe = e.orthographe.statut !== 'relu';
const sources = e.sources_factuelles.length === 0;
const saison = e.categorie === 'fruit' && (e.saison_mois?.length ?? 0) > 0 && e.sources_factuelles.length === 0;
return {
vues,
audio,
orthographe,
sources,
saison,
aucun: vues.length === 0 && !audio && !orthographe && !sources
};
}
/** Entrées jouables dans un mode qui exige une photo d'épreuve. */
export const entreesJouables = (): Entree[] =>
FRUITS.filter((e) => e.medias.some((m) => estPhotoUtilisable(m) && m.usages.includes('epreuve')));
/** Compteurs affichés sur la page de participation — mesurés, jamais estimés. */
export function chiffresProjet() {
const tousMedias = ENTREES.flatMap((e) => e.medias);
return {
entrees: ENTREES.length,
fruits: FRUITS.length,
dangereuses: DANGEREUSES.length,
medias: tousMedias.length,
mediasValides: tousMedias.filter((m) => m.valide).length,
photos: tousMedias.filter((m) => m.type_media === 'photo').length,
planches: tousMedias.filter((m) => m.type_media === 'planche_botanique').length,
audioPresent: ENTREES.filter((e) => e.audio.gcf).length,
orthographeRelue: ENTREES.filter((e) => e.orthographe.statut === 'relu').length,
questionsOuvertes: ENTREES.filter((e) => e.orthographe.question_ouverte).length,
vuesManquantes: ENTREES.reduce((n, e) => n + manques(e).vues.length, 0),
vuesAttendues: ENTREES.length * VUES_ATTENDUES.length
};
}
+100
View File
@@ -0,0 +1,100 @@
/** Types du dataset — miroir de `fruits.schema.json`, qui reste la source d'autorité. */
export type Ile = 'gp' | 'mq' | 'gf' | 're';
export type Categorie = 'fruit' | 'plante_dangereuse';
export type NiveauDanger = 'vigilance' | 'eleve' | 'critique';
export type StatutOrthographe = 'a_relire' | 'en_discussion' | 'relu';
export type Filiere = 'A' | 'B';
export type TypeMedia = 'photo' | 'planche_botanique' | 'image_generee';
export type Vue = 'fruit_entier' | 'coupe' | 'feuille' | 'ecorce' | 'port' | 'fleur' | 'graine';
export type Usage = 'epreuve' | 'indice' | 'fiche' | 'decor';
export interface Media {
id: string;
filiere: Filiere;
type_media: TypeMedia;
vue: Vue | null;
usages: Usage[];
source: 'wikimedia_commons' | 'inaturalist' | 'porteur' | 'herbier' | 'comfyui';
auteur: string | null;
licence: string;
licence_url: string | null;
url_fichier: string;
url_description: string;
largeur: number | null;
hauteur: number | null;
mime: string | null;
description: string | null;
date_prise_de_vue: string | null;
attribution_requise: boolean;
partage_identique: boolean;
usage_non_commercial: boolean;
composite: boolean;
valide: boolean;
valide_par: string | null;
valide_le?: string | null;
note_validation?: string | null;
/** Chemin local du fichier optimisé, rempli par le pipeline d'assets (session 3). */
fichier_local?: string | null;
}
export interface Entree {
id: string;
categorie: Categorie;
noms: {
gcf: string;
fr?: string;
en?: string;
es?: string;
variantes_gcf?: { graphie: string; source: string; ile?: Ile | null; note?: string | null }[];
};
orthographe: {
norme: 'gwadloupeyen';
statut: StatutOrthographe;
relu_par: string | null;
question_ouverte: string | null;
};
taxon: {
scientifique: string;
famille: string;
wikidata: string;
gbif: number;
verifie_le: string;
verifie_contre: string[];
};
iles: Ile[];
saison_mois?: number[];
comestible: boolean;
danger: {
niveau: NiveauDanger;
resume: string;
signalement?: string;
conduite?: string;
} | null;
indices: Partial<Record<'feuille' | 'ecorce' | 'port' | 'coupe' | 'fruit' | 'fleur' | 'latex' | 'graine' | 'usage_haie', string>>;
medias: Media[];
audio: {
gcf: string | null;
locuteur: string | null;
source: 'porteur' | 'lingualibre' | 'contributeur' | null;
licence: string | null;
url_description: string | null;
valide: boolean;
note?: string | null;
};
sources_factuelles: { titre: string; url: string; consulte_le: string }[];
valide: boolean;
$comment?: string;
}
/** Ce qui manque à une entrée. Affiché, jamais masqué (ADR 0006). */
export interface Manques {
/** Vues pédagogiques sans photographie validée. */
vues: Vue[];
audio: boolean;
orthographe: boolean;
sources: boolean;
saison: boolean;
/** Vrai quand rien ne manque — l'état visé, aujourd'hui jamais atteint. */
aucun: boolean;
}
+148
View File
@@ -0,0 +1,148 @@
{
"site.name": "KI FWI SA YÉ ?",
"site.tagline": "Know the fruits of the islands",
"site.desc": "A free educational game to learn the fruits, trees and plants of the Caribbean. Kréyòl first.",
"nav.accueil": "Home",
"nav.zerbaj": "Zerbaj",
"nav.je": "Play",
"nav.kontribiye": "Take part",
"nav.credits": "Credits",
"nav.menu": "Menu",
"nav.langue": "Language",
"nav.theme": "Switch theme",
"nav.aller-contenu": "Skip to content",
"home.titre": "Ki fwi sa yé ?",
"home.intro": "Learn to recognise the fruits, trees and plants of our islands. In kréyòl first, because that is their name.",
"home.jouer": "Play",
"home.explorer": "Browse the encyclopedia",
"home.compteur": "{n} species documented",
"home.pourquoi.titre": "Why this game",
"home.pourquoi.texte": "Many Caribbean children can no longer name the fruits of their island, nor recognise the trees that bear them. This game tries to give that knowledge back.",
"home.verite.titre": "Real photographs, never generated images",
"home.verite.texte": "Every identification task uses a real photograph, freely licensed and checked by a human. Generative models produce fruits that look plausible and are wrong: a game built on them would teach the error.",
"zerbaj.titre": "Zerbaj",
"zerbaj.intro": "The species documented by the project. Each entry also states what it is missing.",
"zerbaj.fruits": "Fruits",
"zerbaj.dangereuses": "Plants not to touch",
"zerbaj.voir": "Open entry",
"fiche.nom-kreyol": "Kréyòl name",
"fiche.autres-noms": "Other names",
"fiche.variantes": "Other forms heard",
"fiche.taxon": "Scientific name",
"fiche.famille": "Family",
"fiche.verifie": "Checked on {date} against Wikidata and GBIF",
"fiche.iles": "Where it grows",
"fiche.saison": "When to find it",
"fiche.saison-non-sourcee": "These months are not checked against any source. To be confirmed.",
"fiche.indices": "How to recognise it",
"fiche.indice.feuille": "The leaf",
"fiche.indice.ecorce": "The bark",
"fiche.indice.port": "The tree",
"fiche.indice.coupe": "Cut open",
"fiche.indice.fruit": "The fruit",
"fiche.indice.fleur": "The flower",
"fiche.indice.latex": "The sap",
"fiche.indice.graine": "The seed",
"fiche.indice.usage_haie": "Where you meet it",
"fiche.danger": "Danger",
"fiche.danger.vigilance": "Take care",
"fiche.danger.eleve": "High danger",
"fiche.danger.critique": "Severe danger",
"fiche.danger.conduite": "What to do",
"fiche.danger.signalement": "How it is marked",
"fiche.comestible.oui": "Edible",
"fiche.comestible.non": "Not to be eaten",
"fiche.medias": "Images",
"fiche.aucune-image": "No validated image for this entry yet.",
"fiche.ecouter": "Hear the name",
"fiche.audio-manquant": "Nobody has recorded this name yet.",
"fiche.sources": "Sources",
"fiche.retour": "Back to the encyclopedia",
"manques.titre": "What this entry is missing",
"manques.intro": "We would rather say it than hide it. Every point below can be filled in, and the tools are free.",
"manques.aider": "How to help",
"manques.photos": "A photograph is missing: {vues}",
"manques.audio": "The name has not been recorded by a human voice yet.",
"manques.orthographe": "The kréyòl spelling has not been reviewed by a speaker yet.",
"manques.sources": "No factual source is cited yet.",
"manques.saison": "The season is not checked against any source.",
"manques.rien": "Nothing is missing here. That is rare — thanks to those who completed it.",
"kreyol.question": "A question for speakers",
"kreyol.repondre": "Answer",
"kreyol.statut.a_relire": "Spelling to be reviewed",
"kreyol.statut.en_discussion": "Spelling under discussion",
"kreyol.statut.relu": "Spelling reviewed by {nom}",
"kontribiye.titre": "Lend a hand",
"kontribiye.intro": "Alone you go faster, together you go further. This project documents species few people have photographed and a language few databases know. Nothing below requires you to be a specialist.",
"kontribiye.outils": "The tools already exist, they are free, and what you put there serves everyone — not just this game.",
"kontribiye.chiffres": "Where we stand today",
"kontribiye.geste": "What you can do",
"kontribiye.duree": "about {n} minutes",
"kontribiye.ensemble": "Every photograph, every name, every voice added to the commons stays available for the children who come next.",
"jeu.titre": "Play",
"jeu.choisir": "Choose a mode",
"jeu.rekonet": "Rekonèt",
"jeu.rekonet.desc": "Recognise a fruit from a photograph.",
"jeu.kache": "Kaché",
"jeu.kache.desc": "Guess from clues revealed one by one.",
"jeu.sezon": "Sézon",
"jeu.sezon.desc": "Place the fruits across the year.",
"jeu.ranmase": "Ranmasé",
"jeu.ranmase.desc": "Harvest in the garden, avoid what stings.",
"jeu.patouche": "Pa touché",
"jeu.patouche.desc": "Plants never to touch. No timer, no score.",
"jeu.commencer": "Start",
"jeu.rejouer": "Play again",
"jeu.suivant": "Next",
"jeu.quitter": "Leave",
"jeu.score": "Score",
"jeu.bonne-reponse": "Correct",
"jeu.mauvaise-reponse": "That was not the one",
"jeu.reponse-etait": "It was {nom}",
"jeu.question": "Which fruit is this?",
"jeu.indice-suivant": "One more clue",
"jeu.indices-restants": "{n} clues left",
"jeu.termine": "Round over",
"jeu.resultat": "{bons} out of {total}",
"jeu.album": "Lakou",
"jeu.album.desc": "The entries you have unlocked.",
"jeu.album.vide": "No entry unlocked yet.",
"jeu.progression.export": "Export my progress",
"jeu.progression.import": "Import progress",
"jeu.progression.effacer": "Erase my progress",
"jeu.indisponible.titre": "This mode cannot be played yet",
"jeu.indisponible.texte": "It needs at least two validated photographs per species, and there are not enough yet. This is not a bug: nobody has contributed them yet.",
"patouche.avertissement": "These plants are dangerous. This module is here to help you recognise and avoid them — it is neither timed nor scored.",
"patouche.conduite": "In case of contact or ingestion",
"patouche.urgence": "If in doubt, call emergency services.",
"footer.projet": "A free project by ORGANISATION KA INTERNATIONALE",
"footer.licence": "Code under AGPL-3.0 · Content under CC BY-SA 4.0",
"footer.sans-compte": "No account, no advertising, no tracker.",
"footer.credits": "Credits and licences",
"credits.titre": "Credits",
"credits.intro": "Nothing is “royalty free”. Every image carries its author and its licence.",
"credits.medias": "Images",
"credits.audio": "Voices",
"credits.sources": "Data sources",
"erreur.404.titre": "Page not found",
"erreur.404.texte": "This page does not exist.",
"erreur.retour": "Back to home",
"erreur.generique": "Something went wrong.",
"offline.titre": "Offline",
"offline.texte": "You are offline. Pages you have already visited remain available.",
"i18n.non-traduit": "This interface has not been translated into kréyòl yet. The text below is in French."
}
+134
View File
@@ -0,0 +1,134 @@
{
"site.name": "KI FWI SA YÉ ?",
"site.tagline": "Reconnaître les fruits du pays",
"site.desc": "Jeu éducatif libre pour apprendre à reconnaître les fruits, les arbres et les plantes des Antilles. En kréyòl d'abord.",
"nav.accueil": "Accueil",
"nav.zerbaj": "Zerbaj",
"nav.je": "Jouer",
"nav.kontribiye": "Participer",
"nav.credits": "Crédits",
"nav.menu": "Menu",
"nav.langue": "Langue",
"nav.theme": "Changer de thème",
"nav.aller-contenu": "Aller au contenu",
"home.titre": "Ki fwi sa yé ?",
"home.intro": "Reconnaître les fruits, les arbres et les plantes de nos îles. En kréyòl d'abord, parce que c'est leur nom.",
"home.jouer": "Jouer",
"home.explorer": "Explorer l'encyclopédie",
"home.compteur": "{n} espèces documentées",
"home.pourquoi.titre": "Pourquoi ce jeu",
"home.pourquoi.texte": "Une partie des enfants des Antilles ne sait plus nommer les fruits de son île, ni reconnaître les arbres qui les portent. Ce jeu essaie de rendre cette connaissance.",
"home.verite.titre": "Des photos réelles, jamais d'images générées",
"home.verite.texte": "Toutes les épreuves de reconnaissance s'appuient sur des photographies réelles, sous licence libre et validées par un humain. Les modèles génératifs produisent des fruits plausibles et faux : un jeu bâti dessus apprendrait l'erreur.",
"zerbaj.titre": "Zerbaj",
"zerbaj.intro": "Les espèces documentées par le projet. Chaque fiche indique aussi ce qui lui manque.",
"zerbaj.fruits": "Fruits",
"zerbaj.dangereuses": "Plantes à ne pas toucher",
"zerbaj.voir": "Voir la fiche",
"fiche.nom-kreyol": "Nom kréyòl",
"fiche.autres-noms": "Autres noms",
"fiche.variantes": "Autres formes entendues",
"fiche.taxon": "Nom scientifique",
"fiche.famille": "Famille",
"fiche.verifie": "Vérifié le {date} contre Wikidata et GBIF",
"fiche.iles": "Où on le trouve",
"fiche.saison": "Quand on le trouve",
"fiche.saison-non-sourcee": "Ces mois ne sont vérifiés contre aucune source. À confirmer.",
"fiche.indices": "Comment le reconnaître",
"fiche.indice.feuille": "La feuille",
"fiche.indice.ecorce": "L'écorce",
"fiche.indice.port": "L'arbre",
"fiche.indice.coupe": "À la coupe",
"fiche.indice.fruit": "Le fruit",
"fiche.indice.fleur": "La fleur",
"fiche.indice.latex": "Le latex",
"fiche.indice.graine": "La graine",
"fiche.indice.usage_haie": "Où on le rencontre",
"fiche.danger": "Danger",
"fiche.danger.vigilance": "Vigilance",
"fiche.danger.eleve": "Danger élevé",
"fiche.danger.critique": "Danger grave",
"fiche.danger.conduite": "Que faire",
"fiche.danger.signalement": "Comment il est signalé",
"fiche.comestible.oui": "Se mange",
"fiche.comestible.non": "Ne se mange pas",
"fiche.medias": "Images",
"fiche.aucune-image": "Aucune image validée pour cette fiche.",
"fiche.ecouter": "Écouter le nom",
"fiche.audio-manquant": "Personne n'a encore enregistré ce nom.",
"fiche.sources": "Sources",
"fiche.retour": "Retour à l'encyclopédie",
"manques.titre": "Ce qui manque à cette fiche",
"manques.intro": "Nous préférons le dire que le cacher. Chacun de ces points peut être comblé, et les outils sont gratuits.",
"manques.aider": "Comment aider",
"manques.photos": "Il manque une photographie : {vues}",
"manques.audio": "Le nom n'a pas encore été enregistré par une voix humaine.",
"manques.orthographe": "L'orthographe kréyòl n'a pas encore été relue par un locuteur.",
"manques.sources": "Aucune source factuelle n'est encore citée.",
"manques.saison": "La saison n'est vérifiée contre aucune source.",
"manques.rien": "Rien ne manque à cette fiche. C'est rare — merci à ceux qui l'ont complétée.",
"kreyol.question": "Une question aux locuteurs",
"kreyol.repondre": "Répondre",
"kreyol.statut.a_relire": "Orthographe à relire",
"kreyol.statut.en_discussion": "Orthographe en discussion",
"kreyol.statut.relu": "Orthographe relue par {nom}",
"kontribiye.titre": "Donner un coup de main",
"kontribiye.intro": "Seul on va plus vite, ensemble on va plus loin. Ce projet documente des espèces que peu de gens ont photographiées et une langue que peu de bases de données connaissent. Rien de ce qui suit ne demande d'être spécialiste.",
"kontribiye.outils": "Les outils existent déjà, ils sont gratuits, et ce qu'on y dépose sert à tout le monde — pas seulement à ce jeu.",
"kontribiye.chiffres": "En chiffres, aujourd'hui",
"kontribiye.geste": "Ce que vous pouvez faire",
"kontribiye.duree": "environ {n} minutes",
"kontribiye.ensemble": "Chaque photo, chaque nom, chaque voix déposée dans les communs reste disponible pour les enfants qui viendront après.",
"jeu.titre": "Jouer",
"jeu.choisir": "Choisir un mode",
"jeu.rekonet": "Rekonèt",
"jeu.rekonet.desc": "Reconnaître un fruit sur une photo.",
"jeu.kache": "Kaché",
"jeu.kache.desc": "Deviner à partir d'indices dévoilés un à un.",
"jeu.sezon": "Sézon",
"jeu.sezon.desc": "Placer les fruits dans l'année.",
"jeu.ranmase": "Ranmasé",
"jeu.ranmase.desc": "Récolter dans le jaden, éviter ce qui pique.",
"jeu.patouche": "Pa touché",
"jeu.patouche.desc": "Les plantes à ne jamais toucher. Sans chrono, sans score.",
"jeu.commencer": "Commencer",
"jeu.rejouer": "Rejouer",
"jeu.suivant": "Suivant",
"jeu.quitter": "Quitter",
"jeu.score": "Score",
"jeu.bonne-reponse": "Bonne réponse",
"jeu.mauvaise-reponse": "Ce n'était pas celui-là",
"jeu.reponse-etait": "C'était {nom}",
"jeu.question": "Quel fruit est-ce ?",
"jeu.indice-suivant": "Un indice de plus",
"jeu.indices-restants": "{n} indices restants",
"jeu.termine": "Partie terminée",
"jeu.resultat": "{bons} sur {total}",
"jeu.album": "Lakou",
"jeu.album.desc": "Les fiches débloquées.",
"jeu.album.vide": "Aucune fiche débloquée pour l'instant.",
"jeu.progression.export": "Exporter ma progression",
"jeu.progression.import": "Importer une progression",
"jeu.progression.effacer": "Effacer ma progression",
"jeu.indisponible.titre": "Ce mode n'est pas encore jouable",
"jeu.indisponible.texte": "Il faut au moins deux photographies validées par espèce, et il n'y en a pas encore assez. Ce n'est pas un bug : personne ne les a encore versées.",
"patouche.avertissement": "Ces plantes sont dangereuses. Ce module sert à les reconnaître pour les éviter — il n'est ni chronométré ni noté.",
"patouche.conduite": "En cas de contact ou d'ingestion",
"patouche.urgence": "En cas de doute, appelez les secours.",
"footer.projet": "Un projet libre d'ORGANISATION KA INTERNATIONALE",
"footer.licence": "Code sous AGPL-3.0 · Contenu sous CC BY-SA 4.0",
"footer.sans-compte": "Aucun compte, aucune publicité, aucun traceur.",
"footer.credits": "Crédits et licences",
"credits.titre": "Crédits",
"credits.intro": "Rien n'est « libre de droit ». Chaque image porte le nom de son auteur et sa licence.",
"credits.medias": "Images",
"credits.audio": "Voix",
"credits.sources": "Sources de données",
"erreur.404.titre": "Page introuvable",
"erreur.404.texte": "Cette page n'existe pas.",
"erreur.retour": "Retour à l'accueil",
"erreur.generique": "Quelque chose s'est mal passé.",
"offline.titre": "Hors ligne",
"offline.texte": "Vous êtes hors ligne. Les pages déjà visitées restent disponibles.",
"i18n.non-traduit": "Cette interface n'est pas encore traduite en kréyòl. Le texte ci-dessous est en français."
}
+150
View File
@@ -0,0 +1,150 @@
{
"$comment": "Catalogue kréyòl gwadloupéyen. Ne contient QUE les termes fournis par le porteur du projet dans ses documents de cadrage. Toute autre chaîne vaut null : aucun agent de code n'écrit de kréyòl (ADR 0005). Une valeur null se replie sur le français, avec l'attribut lang correct. La liste de ce qui reste à traduire est produite par `npm run check:i18n` dans docs/i18n-a-traduire.md — c'est une tâche de participation, pas une dette technique.",
"site.name": "KI FWI SA YÉ ?",
"site.tagline": null,
"site.desc": null,
"nav.accueil": null,
"nav.zerbaj": "Zerbaj",
"nav.je": null,
"nav.kontribiye": null,
"nav.credits": null,
"nav.menu": null,
"nav.langue": null,
"nav.theme": null,
"nav.aller-contenu": null,
"home.titre": "Ki fwi sa yé ?",
"home.intro": null,
"home.jouer": null,
"home.explorer": null,
"home.compteur": null,
"home.pourquoi.titre": null,
"home.pourquoi.texte": null,
"home.verite.titre": null,
"home.verite.texte": null,
"zerbaj.titre": "Zerbaj",
"zerbaj.intro": null,
"zerbaj.fruits": null,
"zerbaj.dangereuses": null,
"zerbaj.voir": null,
"fiche.nom-kreyol": null,
"fiche.autres-noms": null,
"fiche.variantes": null,
"fiche.taxon": null,
"fiche.famille": null,
"fiche.verifie": null,
"fiche.iles": null,
"fiche.saison": null,
"fiche.saison-non-sourcee": null,
"fiche.indices": null,
"fiche.indice.feuille": null,
"fiche.indice.ecorce": null,
"fiche.indice.port": null,
"fiche.indice.coupe": null,
"fiche.indice.fruit": null,
"fiche.indice.fleur": null,
"fiche.indice.latex": null,
"fiche.indice.graine": null,
"fiche.indice.usage_haie": null,
"fiche.danger": null,
"fiche.danger.vigilance": null,
"fiche.danger.eleve": null,
"fiche.danger.critique": null,
"fiche.danger.conduite": null,
"fiche.danger.signalement": null,
"fiche.comestible.oui": null,
"fiche.comestible.non": null,
"fiche.medias": null,
"fiche.aucune-image": null,
"fiche.ecouter": null,
"fiche.audio-manquant": null,
"fiche.sources": null,
"fiche.retour": null,
"manques.titre": null,
"manques.intro": null,
"manques.aider": null,
"manques.photos": null,
"manques.audio": null,
"manques.orthographe": null,
"manques.sources": null,
"manques.saison": null,
"manques.rien": null,
"kreyol.question": null,
"kreyol.repondre": null,
"kreyol.statut.a_relire": null,
"kreyol.statut.en_discussion": null,
"kreyol.statut.relu": null,
"kontribiye.titre": null,
"kontribiye.intro": null,
"kontribiye.outils": null,
"kontribiye.chiffres": null,
"kontribiye.geste": null,
"kontribiye.duree": null,
"kontribiye.ensemble": null,
"jeu.titre": null,
"jeu.choisir": null,
"jeu.rekonet": "Rekonèt",
"jeu.rekonet.desc": null,
"jeu.kache": "Kaché",
"jeu.kache.desc": null,
"jeu.sezon": "Sézon",
"jeu.sezon.desc": null,
"jeu.ranmase": "Ranmasé",
"jeu.ranmase.desc": null,
"jeu.patouche": "Pa touché",
"jeu.patouche.desc": null,
"jeu.commencer": null,
"jeu.rejouer": null,
"jeu.suivant": null,
"jeu.quitter": null,
"jeu.score": null,
"jeu.bonne-reponse": null,
"jeu.mauvaise-reponse": null,
"jeu.reponse-etait": null,
"jeu.question": null,
"jeu.indice-suivant": null,
"jeu.indices-restants": null,
"jeu.termine": null,
"jeu.resultat": null,
"jeu.album": "Lakou",
"jeu.album.desc": null,
"jeu.album.vide": null,
"jeu.progression.export": null,
"jeu.progression.import": null,
"jeu.progression.effacer": null,
"jeu.indisponible.titre": null,
"jeu.indisponible.texte": null,
"patouche.avertissement": null,
"patouche.conduite": null,
"patouche.urgence": null,
"footer.projet": null,
"footer.licence": null,
"footer.sans-compte": null,
"footer.credits": null,
"credits.titre": null,
"credits.intro": null,
"credits.medias": null,
"credits.audio": null,
"credits.sources": null,
"erreur.404.titre": null,
"erreur.404.texte": null,
"erreur.retour": null,
"erreur.generique": null,
"offline.titre": null,
"offline.texte": null,
"i18n.non-traduit": null
}
+167
View File
@@ -0,0 +1,167 @@
/**
* i18n par dossiers, sans middleware (playbook §5b.7).
*
* Le kréyòl est la langue principale : `/` sert `gcf`, `/fr/` et `/en/` sont les
* traductions (ADR 0005). Ce n'est pas cosmétique, et cela ne s'inverse pas au motif
* que le français serait « plus pratique ».
*
* Repli assumé : le catalogue `gcf` ne contient que les termes fournis par le porteur.
* Toute chaîne non traduite se replie sur le français, et le composant qui l'affiche
* pose `lang="fr"` sur ce fragment — un lecteur d'écran ne doit pas prononcer du
* français avec la phonétique kréyòl, ni l'inverse.
*/
import gcf from './gcf.json';
import fr from './fr.json';
import en from './en.json';
export const LOCALES = ['gcf', 'fr', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const LOCALE_DEFAUT: Locale = 'gcf';
/** Libellés du sélecteur de langue, toujours dans leur propre langue. */
export const NOM_LOCALE: Record<Locale, string> = {
gcf: 'Kréyòl',
fr: 'Français',
en: 'English'
};
/** Code BCP-47 posé sur l'attribut `lang`. */
export const BALISE_LANG: Record<Locale, string> = {
gcf: 'gcf',
fr: 'fr',
en: 'en'
};
type Catalogue = Record<string, string | null>;
const CATALOGUES: Record<Locale, Catalogue> = {
gcf: gcf as Catalogue,
fr: fr as Catalogue,
en: en as Catalogue
};
export type Cle = keyof typeof fr;
/** Résultat d'une traduction : le texte, et la langue dans laquelle il est réellement écrit. */
export interface Traduction {
texte: string;
/** Langue effective — diffère de la locale demandée quand on s'est replié. */
lang: Locale;
/** Vrai quand la chaîne demandée n'existait pas dans la locale demandée. */
repli: boolean;
}
function interpoler(modele: string, vars?: Record<string, string | number>): string {
if (!vars) return modele;
return modele.replace(/\{(\w+)\}/g, (tout, nom) => (nom in vars ? String(vars[nom]) : tout));
}
/**
* Traduit une clé, en indiquant la langue réellement obtenue.
*
* Ordre de repli : locale demandée → français → anglais → la clé elle-même.
* La clé nue est visible et laide : c'est voulu, une chaîne manquante doit se voir.
*/
export function traduire(locale: Locale, cle: Cle, vars?: Record<string, string | number>): Traduction {
const direct = CATALOGUES[locale]?.[cle];
if (direct) return { texte: interpoler(direct, vars), lang: locale, repli: false };
const francais = CATALOGUES.fr[cle];
if (francais) return { texte: interpoler(francais, vars), lang: 'fr', repli: true };
const anglais = CATALOGUES.en[cle];
if (anglais) return { texte: interpoler(anglais, vars), lang: 'en', repli: true };
return { texte: String(cle), lang: 'fr', repli: true };
}
/** Raccourci quand la langue effective n'a pas d'importance pour le rendu (attributs, titres). */
export function t(locale: Locale, cle: Cle, vars?: Record<string, string | number>): string {
return traduire(locale, cle, vars).texte;
}
/** Fabrique un traducteur lié à une locale — évite de répéter la locale à chaque appel. */
export function traducteur(locale: Locale) {
return {
t: (cle: Cle, vars?: Record<string, string | number>) => t(locale, cle, vars),
tr: (cle: Cle, vars?: Record<string, string | number>) => traduire(locale, cle, vars)
};
}
/** Déduit la locale du premier segment du chemin. `/` et tout le reste = kréyòl. */
export function localeFromPath(pathname: string): Locale {
const segment = pathname.split('/').filter(Boolean)[0];
return LOCALES.includes(segment as Locale) && segment !== 'gcf' ? (segment as Locale) : LOCALE_DEFAUT;
}
/** Préfixe d'URL d'une locale : le kréyòl vit à la racine, il n'a pas de préfixe. */
export function prefixe(locale: Locale): string {
return locale === LOCALE_DEFAUT ? '' : `/${locale}`;
}
/**
* Construit un chemin interne localisé, toujours avec barre oblique finale
* (`trailingSlash: 'always'`, sinon le prerender écrit des redirections).
*/
export function chemin(locale: Locale, ...segments: string[]): string {
const corps = segments.filter(Boolean).join('/');
const p = `${prefixe(locale)}/${corps}`;
return p.endsWith('/') ? p : `${p}/`;
}
/**
* Routes utilitaires qui n'existent qu'en un seul exemplaire, hors arbre localisé :
* la page hors-ligne est la cible du service worker, une seule URL suffit.
*/
const ROUTES_NON_LOCALISEES = ['offline'];
/**
* Le même contenu dans une autre langue — sert au sélecteur et aux liens hreflang.
*
* Sur une route non localisée, renvoie la racine de la langue cible : proposer
* `/fr/offline/` créerait un lien vers une page qui n'existe pas, et le prerender
* en mode strict fait échouer le build — ce qui est le comportement voulu.
*/
export function cheminAlterne(pathnameActuel: string, cible: Locale): string {
const segments = pathnameActuel.split('/').filter(Boolean);
if (segments.length && ROUTES_NON_LOCALISEES.includes(segments[0])) return chemin(cible);
const actuelle = localeFromPath(pathnameActuel);
if (actuelle !== LOCALE_DEFAUT) segments.shift();
return chemin(cible, ...segments);
}
/** Nombre de chaînes non traduites dans une locale — alimente le rapport de participation. */
export function chainesManquantes(locale: Locale): string[] {
return Object.keys(fr).filter((cle) => !CATALOGUES[locale][cle]);
}
/** Part des chaînes réellement disponibles dans une locale, entre 0 et 1. */
export function couverture(locale: Locale): number {
const total = Object.keys(fr).length;
return (total - chainesManquantes(locale).length) / total;
}
/** En dessous de ce seuil, la page est majoritairement écrite dans la langue de repli. */
const SEUIL_LANG_DOCUMENT = 0.6;
/**
* Langue à poser sur `<html lang>` — celle dans laquelle la page est *réellement* écrite,
* pas celle qu'on voudrait.
*
* Le catalogue kréyòl est aujourd'hui à environ 10 % : annoncer `lang="gcf"` sur une page
* dont le texte est français ferait lire ce français avec la phonétique kréyòl par un
* lecteur d'écran. On annonce donc `fr`, et on marque individuellement les fragments
* réellement kréyòl — noms de fruits, noms de modes — avec `lang="gcf"`.
*
* Cette fonction basculera d'elle-même sur `gcf` quand la traduction dépassera le seuil.
* C'est le seul endroit à changer, et il n'y a rien à changer : il faut traduire.
*/
export function langDocument(locale: Locale): string {
return couverture(locale) >= SEUIL_LANG_DOCUMENT ? BALISE_LANG[locale] : BALISE_LANG.fr;
}
/** Vrai quand l'interface de cette locale est majoritairement affichée dans une autre langue. */
export const interfaceNonTraduite = (locale: Locale): boolean => couverture(locale) < SEUIL_LANG_DOCUMENT;
+261
View File
@@ -0,0 +1,261 @@
/**
* Primitives partagées — playbook §4.
* Reset minimal, composants canoniques, gate reduced-motion unique.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
background: var(--noir-oki);
color-scheme: dark;
}
html.light-theme {
color-scheme: light;
}
body {
margin: 0;
background: var(--noir-oki);
color: var(--blanc-creme);
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: -0.01em;
line-height: 1.1;
margin: 0 0 var(--space-2);
text-wrap: balance;
}
h1 {
font-size: clamp(1.9rem, 5vw, 3.2rem);
}
h2 {
font-size: clamp(1.4rem, 3.5vw, 2.1rem);
}
h3 {
font-size: clamp(1.1rem, 2.5vw, 1.4rem);
}
p {
margin: 0 0 var(--space-2);
max-width: 68ch;
}
/* Liens soulignés dans le contenu seulement — sinon nav et pied de page héritent
d'un souligné partout (playbook §5, leçon 10 : Lighthouse link-in-text-block). */
main a {
color: var(--or-oki);
text-decoration: underline;
text-underline-offset: 0.2em;
}
main a:hover {
color: var(--or-clair);
}
a {
color: var(--or-oki);
}
:focus-visible {
outline: 3px solid var(--or-oki);
outline-offset: 2px;
}
img {
max-width: 100%;
height: auto;
}
/* ── Primitives ───────────────────────────────────────────────────────── */
.container {
width: min(100% - 2rem, var(--container));
margin-inline: auto;
}
.section {
padding: var(--space-5) 0;
}
.grid {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
}
/* Flag-bar — signature n°1. Segments francs, jamais de dégradé entre eux. */
.flag-bar {
height: 6px;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
html.light-theme .flag-bar {
background: linear-gradient(
to right,
#000 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
.flag-chip {
display: inline-block;
width: 56px;
height: 6px;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
border: 1px solid var(--line);
}
.card {
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-sm);
padding: var(--space-3);
transition:
transform var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka);
}
a.card {
display: block;
text-decoration: none;
color: inherit;
}
a.card:hover,
a.card:focus-visible {
transform: translateY(-3px);
border-color: var(--or-oki);
}
.tag {
display: inline-block;
padding: 0.15em 0.6em;
font-size: 0.8rem;
background: color-mix(in srgb, var(--or-oki) 8%, transparent);
border: 1px solid var(--or-oki);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
}
.tag--danger {
background: color-mix(in srgb, var(--rouge-oki) 10%, transparent);
border-color: var(--rouge-oki);
}
.tag--ok {
background: color-mix(in srgb, var(--vert-oki) 10%, transparent);
border-color: var(--vert-oki);
}
/* Bouton canonique : balayage or au survol, cibles tactiles ≥ 44px. */
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.6em 1.4em;
font-family: var(--font-display);
font-weight: 700;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.03em;
text-decoration: none;
color: var(--or-oki);
background: transparent;
border: var(--border-btn);
border-radius: var(--radius-sm);
cursor: pointer;
overflow: hidden;
isolation: isolate;
}
.btn::after {
content: '';
position: absolute;
inset: 0;
background: var(--or-oki);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dur-tanbou) var(--ease-syncope);
z-index: -1;
}
.btn:hover::after,
.btn:focus-visible::after {
transform: scaleX(1);
}
.btn:hover,
.btn:focus-visible {
color: var(--noir-oki);
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: var(--muted);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
padding: var(--space-1) var(--space-2);
background: var(--or-oki);
color: var(--noir-oki);
font-family: var(--font-display);
text-transform: uppercase;
}
.skip-link:focus {
left: 0;
}
/* ── Gate unique prefers-reduced-motion (playbook §2.8) ───────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Polices self-hébergées — doctrine de souveraineté numérique.
* Aucun appel à Google Fonts, aucun CDN : les woff2 vivent dans static/fonts/.
* Source : @fontsource/archivo et @fontsource/inter, sous-ensemble latin uniquement.
*/
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/archivo-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/archivo-latin-700-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('/fonts/archivo-latin-800-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/inter-latin-600-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/inter-latin-700-normal.woff2') format('woff2');
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Tokens de la charte ORGANISATION KA INTERNATIONALE.
* Playbook §1 — valeurs canoniques, jamais recopiées ailleurs.
*
* Trois règles non négociables :
* 1. thème sombre = identité par défaut, le clair est un opt-in ;
* 2. l'or porte TOUTE l'interaction — seule couleur d'action ;
* 3. angles nets partout, aucune forme organique.
*
* Rouge = signaler · Vert = valider · Or = agir. Ne jamais permuter.
*/
:root {
/* Noyau — thème sombre par défaut */
--noir-oki: #0d0d0d;
--noir-profond: #1a0f1a;
--blanc-creme: #fff8e7;
--line: rgba(255, 255, 255, 0.1);
--or-oki: #fdb813;
--rouge-oki: #ff1654;
/* Étendue — à employer avec parcimonie */
--vert-oki: #00d66c;
--turquoise-caraibes: #00ced1;
--jaune-soleil: #ffd700;
--orange-flamme: #ff6b35;
--violet-nuit: #6b2d5c;
--bleu-ocean: #0077be;
--or-clair: #ffe066;
--gris-sombre: #2d1b2e;
/* Sémantique dérivée */
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
--card-bg: rgba(255, 255, 255, 0.03);
/* Typographie */
--font-display: 'Archivo', 'Arial Black', sans-serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Formes — jamais au-delà de 6px */
--radius-sm: 3px;
--radius-md: 6px;
--border-card: 1px solid var(--line);
--border-btn: 2px solid var(--or-oki);
/* Espacements */
--space-1: 0.75rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 4rem;
--container: 1200px;
/* Motion — les cadences gwoka (playbook §1.7) */
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1);
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1);
--dur-tanbou: 120ms;
--dur-mesure: 480ms;
--dur-phrase: 960ms;
}
/* Thème clair — opt-in, accents assombris pour rester en AA */
html.light-theme {
--noir-oki: #fff8e7;
--blanc-creme: #0d0d0d;
--noir-profond: #f5f0e8;
--gris-sombre: #e8ddd0;
--or-oki: #b87a00;
--vert-oki: #006b3d;
--rouge-oki: #a01030;
--turquoise-caraibes: #006b75;
--bleu-ocean: #004b7f;
--or-clair: #d99000;
--jaune-soleil: #b87a00;
--orange-flamme: #c85000;
--line: rgba(0, 0, 0, 0.12);
--card-bg: rgba(0, 0, 0, 0.03);
}
+7
View File
@@ -0,0 +1,7 @@
import type { ParamMatcher } from '@sveltejs/kit';
/**
* Ne matche que les langues traduites. Le kréyòl vit à la racine, sans préfixe :
* il n'apparaît donc jamais ici (ADR 0005).
*/
export const match: ParamMatcher = (param) => param === 'fr' || param === 'en';
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { page } from '$app/state';
import { chemin, localeFromPath, t } from '$lib/i18n';
const locale = $derived(localeFromPath(page.url.pathname));
const introuvable = $derived(page.status === 404);
</script>
<svelte:head>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="container section centre">
<span class="flag-chip" aria-hidden="true"></span>
<h1>{introuvable ? t(locale, 'erreur.404.titre') : t(locale, 'erreur.generique')}</h1>
<p>{introuvable ? t(locale, 'erreur.404.texte') : (page.error?.message ?? '')}</p>
<p><a class="btn" href={chemin(locale)}>{t(locale, 'erreur.retour')}</a></p>
</div>
<style>
.centre {
text-align: center;
display: grid;
justify-items: center;
gap: var(--space-1);
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import '$lib/styles/oki-tokens.css';
import '$lib/styles/fonts.css';
import '$lib/styles/base.css';
import { page } from '$app/state';
import Nav from '$lib/components/Nav.svelte';
import Footer from '$lib/components/Footer.svelte';
import { interfaceNonTraduite, localeFromPath, t } from '$lib/i18n';
const { children } = $props();
const locale = $derived(localeFromPath(page.url.pathname));
const repli = $derived(interfaceNonTraduite(locale));
</script>
<Nav {locale} />
{#if repli}
<!-- Dire que l'interface n'est pas encore dans la langue annoncée, plutôt que de
laisser croire qu'elle l'est. C'est aussi un appel : cette traduction est à faire. -->
<p class="avis container">
{t(locale, 'i18n.non-traduit')}
</p>
{/if}
<main id="contenu" tabindex="-1">
{@render children?.()}
</main>
<Footer {locale} />
<style>
main {
min-height: 60vh;
}
main:focus {
outline: none;
}
.avis {
margin-block: var(--space-2) 0;
padding: var(--space-1) var(--space-2);
font-size: 0.85rem;
color: var(--muted);
background: var(--card-bg);
border-left: 4px solid var(--or-oki);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// Prérendu intégral : chaque page est un fichier. C'est la condition du hors-ligne,
// de l'hébergement statique et du zéro-tiers.
export const prerender = true;
export const trailingSlash = 'always';
+10
View File
@@ -0,0 +1,10 @@
import { LOCALE_DEFAUT, type Locale } from '$lib/i18n';
import type { LayoutLoad } from './$types';
/**
* La locale vient du premier segment d'URL, pas d'un cookie ni d'un en-tête :
* chaque langue a ses propres URLs prérendues, indexables et partageables.
*/
export const load: LayoutLoad = ({ params }) => ({
locale: (params.locale ?? LOCALE_DEFAUT) as Locale
});
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { chiffresProjet } from '$lib/data';
const { data } = $props();
const locale = $derived(data.locale);
const c = chiffresProjet();
const jsonLd = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'KI FWI SA YÉ ?',
url: 'https://ki-fwi.o-k-i.net',
inLanguage: ['gcf', 'fr', 'en'],
description:
"Jeu éducatif libre pour apprendre à reconnaître les fruits, les arbres et les plantes des Antilles.",
publisher: {
'@type': 'Organization',
name: 'ORGANISATION KA INTERNATIONALE',
url: 'https://o-k-i.net'
},
license: 'https://creativecommons.org/licenses/by-sa/4.0/'
});
</script>
<Seo {locale} pathname={page.url.pathname} {jsonLd} />
<section class="hero container section">
<span class="flag-chip" aria-hidden="true"></span>
<h1 lang="gcf">{t(locale, 'home.titre')}</h1>
<p class="intro">{t(locale, 'home.intro')}</p>
<p class="actions">
<a class="btn" href={chemin(locale, 'je')}>{t(locale, 'home.jouer')}</a>
<a class="btn btn--sobre" href={chemin(locale, 'zerbaj')}>{t(locale, 'home.explorer')}</a>
</p>
<p class="muted compteur">{t(locale, 'home.compteur', { n: c.entrees })}</p>
</section>
<section class="container section">
<div class="grid">
<article class="card">
<h2>{t(locale, 'home.pourquoi.titre')}</h2>
<p>{t(locale, 'home.pourquoi.texte')}</p>
</article>
<article class="card">
<h2>{t(locale, 'home.verite.titre')}</h2>
<p>{t(locale, 'home.verite.texte')}</p>
</article>
<article class="card card--appel">
<h2>{t(locale, 'kontribiye.titre')}</h2>
<p>{t(locale, 'kontribiye.intro')}</p>
<p>
<a class="btn" href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
</p>
</article>
</div>
</section>
<style>
.hero {
padding-block: var(--space-5) var(--space-4);
}
.hero .flag-chip {
margin-bottom: var(--space-2);
}
.intro {
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
max-width: 46ch;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
}
.btn--sobre {
color: var(--blanc-creme);
border-color: var(--line);
}
.btn--sobre:hover,
.btn--sobre:focus-visible {
color: var(--noir-oki);
border-color: var(--or-oki);
}
.compteur {
margin-top: var(--space-3);
font-size: 0.9rem;
}
.card--appel {
border-left-color: var(--vert-oki);
}
</style>
@@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { entreesJouables, FRUITS } from '$lib/data';
const { data } = $props();
const locale = $derived(data.locale);
const jouables = entreesJouables().length;
/**
* `livre` bascule à vrai quand la route du mode existe réellement.
* Session 1 livre la coquille ; les modes arrivent en session 2 (Svelte pur),
* puis en session 4 pour l'arcade. Annoncer un mode sans le livrer produirait
* un lien mort — et le prerender strict fait échouer le build, ce qui est voulu.
*/
const modes = $derived([
{ id: 'rekonet', cle: 'jeu.rekonet', desc: 'jeu.rekonet.desc', route: 'rekonet', besoinPhotos: true, livre: false },
{ id: 'kache', cle: 'jeu.kache', desc: 'jeu.kache.desc', route: 'kache', besoinPhotos: false, livre: false },
{ id: 'sezon', cle: 'jeu.sezon', desc: 'jeu.sezon.desc', route: 'sezon', 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 }
] as const);
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.titre')} />
<div class="container section">
<h1>{t(locale, 'jeu.titre')}</h1>
<p class="muted">{t(locale, 'jeu.choisir')}</p>
<ul class="grid modes">
{#each modes as mode (mode.id)}
{@const bloque = mode.besoinPhotos && jouables < 4}
<li>
{#if mode.livre && !bloque}
<a class="card" class:card--sur={mode.id === 'patouche'} href={chemin(locale, 'je', mode.route)}>
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
<p>{t(locale, mode.desc)}</p>
</a>
{:else}
<div class="card card--bloque">
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
<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>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
<p class="etat muted">
{jouables} espèce{jouables > 1 ? 's' : ''} sur {FRUITS.length} dispose dune photographie validée.
</p>
</div>
<style>
.modes {
list-style: none;
margin: var(--space-3) 0;
padding: 0;
}
.modes h2 {
font-size: 1.3rem;
margin-bottom: 0.35rem;
}
.card--bloque {
border-left-color: var(--line);
opacity: 0.85;
}
.card--sur {
border-left-color: var(--rouge-oki);
}
.petit {
font-size: 0.85rem;
}
.etat {
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,182 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, chainesManquantes, t } from '$lib/i18n';
import { ENTREES, chiffresProjet, manques } from '$lib/data';
const { data } = $props();
const locale = $derived(data.locale);
const c = chiffresProjet();
const chainesGcf = chainesManquantes('gcf').length;
/** Les vues qui manquent le plus souvent — la mesure décide de la priorité, pas l'intuition. */
const vuesManquantes = ENTREES.flatMap((e) => manques(e).vues);
const parVue = vuesManquantes.reduce<Record<string, number>>((acc, v) => {
acc[v] = (acc[v] ?? 0) + 1;
return acc;
}, {});
const LIBELLE_VUE: Record<string, string> = {
feuille: 'feuille',
ecorce: 'écorce',
coupe: 'fruit coupé',
fruit_entier: 'fruit entier'
};
const classement = Object.entries(parVue).sort((a, b) => b[1] - a[1]);
/**
* Liens sortants uniquement. Le jeu ne contacte aucun service tiers pendant qu'on
* y joue (ADR 0006) : contribuer se fait sur les plateformes des communs, où le
* travail sert à tout le monde et pas seulement à ce projet.
*/
const gestes = [
{
titre: 'Inscrire un nom kréyòl sur Wikidata',
minutes: 5,
texte:
"Aucun matériel, aucun déplacement. Wikidata enregistre 124 noms de fruits pour ces espèces — en chinois, en anglais, en portugais — et aucun en kréyòl. Un nom qui n'existe que dans ce jeu reste perdu partout ailleurs.",
lien: 'https://www.wikidata.org',
libelleLien: 'wikidata.org'
},
{
titre: 'Décrire une photo qui existe déjà',
minutes: 10,
texte:
"La recherche de Wikimedia Commons porte sur le texte des pages, pas sur le contenu des images : une photo d'écorce que personne n'a décrite est introuvable. La compléter ne demande qu'un navigateur, et ne touche pas à l'image.",
lien: 'https://commons.wikimedia.org',
libelleLien: 'commons.wikimedia.org'
},
{
titre: 'Photographier ce qui manque',
minutes: 30,
texte:
"Un tronc de près, une feuille à plat, un fruit coupé net. C'est la contribution la plus utile, et la plus rare : on photographie un fruit, presque jamais une écorce.",
lien: 'https://commons.wikimedia.org/wiki/Special:UploadWizard',
libelleLien: 'verser sur Commons'
},
{
titre: 'Enregistrer une prononciation',
minutes: 10,
texte:
"Lingua Libre est l'atelier d'enregistrement de Wikimedia. 456 mots en kréyòl gwadloupéyen y sont déjà déposés par des locuteurs. Une voix locale vaut mieux qu'un studio, et aucune synthèse vocale ne sera jamais utilisée ici.",
lien: 'https://lingualibre.org',
libelleLien: 'lingualibre.org'
},
{
titre: 'Observer sur Pl@ntNet',
minutes: 5,
texte:
"Pl@ntNet verse ses observations sous licence libre réutilisable, là où la plupart des photos d'iNaturalist portent une clause non commerciale. À effort égal, la contribution y sert davantage de monde.",
lien: 'https://identify.plantnet.org',
libelleLien: 'plantnet.org'
},
{
titre: "Traduire l'interface en kréyòl",
minutes: 20,
texte: `Ce site affiche du français là où il devrait afficher du kréyòl : ${chainesGcf} phrases restent à traduire. Elles ne peuvent pas être écrites par un automate — c'est justement le sujet.`,
lien: 'https://o-k-i.net',
libelleLien: 'nous écrire'
}
];
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'kontribiye.titre')} />
<div class="container section">
<span class="flag-chip" aria-hidden="true"></span>
<h1>{t(locale, 'kontribiye.titre')}</h1>
<p class="intro">{t(locale, 'kontribiye.intro')}</p>
<p>{t(locale, 'kontribiye.outils')}</p>
<section aria-labelledby="chiffres-titre">
<h2 id="chiffres-titre">{t(locale, 'kontribiye.chiffres')}</h2>
<ul class="chiffres">
<li><strong>{c.entrees}</strong> espèces documentées</li>
<li><strong>{c.mediasValides}</strong> images validées sur {c.medias}</li>
<li><strong>{c.vuesManquantes}</strong> vues manquantes sur {c.vuesAttendues}</li>
<li><strong>{c.audioPresent}</strong> prononciations sur {c.entrees}</li>
<li><strong>{c.orthographeRelue}</strong> orthographes relues sur {c.entrees}</li>
<li><strong>{chainesGcf}</strong> phrases dinterface à traduire en kréyòl</li>
</ul>
{#if classement.length}
<h3>Ce qui manque le plus</h3>
<ul class="priorites">
{#each classement as [vue, n] (vue)}
<li>
<span class="tag">{LIBELLE_VUE[vue] ?? vue}</span>
manque à <strong>{n}</strong> espèce{n > 1 ? 's' : ''}
</li>
{/each}
</ul>
{/if}
</section>
<section aria-labelledby="gestes-titre">
<h2 id="gestes-titre">{t(locale, 'kontribiye.geste')}</h2>
<div class="grid">
{#each gestes as g (g.titre)}
<article class="card">
<p class="duree muted">{t(locale, 'kontribiye.duree', { n: g.minutes })}</p>
<h3>{g.titre}</h3>
<p>{g.texte}</p>
<p>
<a href={g.lien} rel="external noopener noreferrer" target="_blank">{g.libelleLien}</a>
</p>
</article>
{/each}
</div>
</section>
<p class="ensemble">{t(locale, 'kontribiye.ensemble')}</p>
<p><a class="btn" href={chemin(locale, 'zerbaj')}>{t(locale, 'zerbaj.titre')}</a></p>
</div>
<style>
.intro {
font-size: clamp(1.05rem, 2.2vw, 1.25rem);
max-width: 52ch;
}
section {
margin-block: var(--space-4);
}
.chiffres,
.priorites {
list-style: none;
margin: 0 0 var(--space-2);
padding: 0;
}
.chiffres li,
.priorites li {
padding: 0.4rem 0;
border-bottom: 1px solid var(--line);
}
.chiffres strong,
.priorites strong {
font-family: var(--font-display);
font-size: 1.15rem;
color: var(--or-oki);
}
.duree {
margin: 0 0 0.25rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ensemble {
margin-top: var(--space-4);
padding-left: var(--space-2);
border-left: 4px solid var(--vert-oki);
font-size: 1.05rem;
max-width: 56ch;
}
</style>
@@ -0,0 +1,112 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { t } from '$lib/i18n';
import { ENTREES } from '$lib/data';
const { data } = $props();
const locale = $derived(data.locale);
/** Les crédits sont dérivés des données, jamais saisis à la main : une attribution
recopiée se désynchronise, et une attribution fausse est une violation de licence. */
const medias = ENTREES.flatMap((e) => e.medias.map((m) => ({ ...m, entree: e })));
const voix = ENTREES.filter((e) => e.audio.gcf);
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'credits.titre')} />
<div class="container section">
<h1>{t(locale, 'credits.titre')}</h1>
<p class="intro">{t(locale, 'credits.intro')}</p>
<section aria-labelledby="medias-titre">
<h2 id="medias-titre">{t(locale, 'credits.medias')} ({medias.length})</h2>
<ul class="liste">
{#each medias as m (m.entree.id + m.id)}
<li>
<span lang="gcf">{m.entree.noms.gcf}</span>
<span class="auteur">{m.auteur ?? '—'}</span>
<span class="tag">{m.licence}</span>
<a href={m.url_description} rel="external noopener noreferrer" target="_blank">source</a>
</li>
{/each}
</ul>
</section>
<section aria-labelledby="voix-titre">
<h2 id="voix-titre">{t(locale, 'credits.audio')} ({voix.length})</h2>
{#if voix.length === 0}
<p class="muted">
Aucune voix enregistrée pour linstant. Les locuteurs conservent leurs droits et sont
crédités nominativement.
</p>
{:else}
<ul class="liste">
{#each voix as e (e.id)}
<li>
<span lang="gcf">{e.noms.gcf}</span>{e.audio.locuteur}
<span class="tag">{e.audio.licence}</span>
</li>
{/each}
</ul>
{/if}
</section>
<section aria-labelledby="sources-titre">
<h2 id="sources-titre">{t(locale, 'credits.sources')}</h2>
<ul class="liste">
<li>
<a href="https://www.wikidata.org" rel="external noopener noreferrer" target="_blank">Wikidata</a>
— identifiants et taxonomie (CC0)
</li>
<li>
<a href="https://www.gbif.org" rel="external noopener noreferrer" target="_blank">GBIF</a>
— noms scientifiques acceptés
</li>
<li>
<a href="https://commons.wikimedia.org" rel="external noopener noreferrer" target="_blank">
Wikimedia Commons
</a>
— photographies
</li>
<li>
<a href="https://lingualibre.org" rel="external noopener noreferrer" target="_blank">Lingua Libre</a>
— prononciations
</li>
</ul>
<h3>Licences</h3>
<p>
Code sous <strong>AGPL-3.0-or-later</strong>. Contenu rédigé par ORGANISATION KA
INTERNATIONALE sous <strong>CC BY-SA 4.0</strong>. Les médias tiers restent sous leur licence
propre : ce site est une œuvre collective, pas une œuvre dérivée — aucun média nest
recomposé ni fusionné.
</p>
</section>
</div>
<style>
.intro {
max-width: 56ch;
}
section {
margin-block: var(--space-4);
}
.liste {
list-style: none;
margin: 0;
padding: 0;
}
.liste li {
padding: 0.4rem 0;
border-bottom: 1px solid var(--line);
font-size: 0.9rem;
}
.auteur {
color: var(--muted);
}
</style>
@@ -0,0 +1,101 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import Manques from '$lib/components/Manques.svelte';
import { chemin, t } from '$lib/i18n';
import { DANGEREUSES, FRUITS, NOM_ILE } from '$lib/data';
const { data } = $props();
const locale = $derived(data.locale);
const jsonLd = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Zerbaj',
inLanguage: 'gcf',
hasPart: [...FRUITS, ...DANGEREUSES].map((e) => ({
'@type': 'Thing',
name: e.noms.gcf,
alternateName: e.taxon.scientifique,
url: `https://ki-fwi.o-k-i.net${chemin('gcf', 'zerbaj', e.id)}`
}))
});
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'zerbaj.titre')} {jsonLd} />
<div class="container section">
<h1 lang="gcf">{t(locale, 'zerbaj.titre')}</h1>
<p class="intro">{t(locale, 'zerbaj.intro')}</p>
<h2>{t(locale, 'zerbaj.fruits')}</h2>
<ul class="grid liste">
{#each FRUITS as e (e.id)}
<li>
<a class="card" href={chemin(locale, 'zerbaj', e.id)}>
<h3 lang="gcf">{e.noms.gcf}</h3>
<p class="muted latin">{e.taxon.scientifique}</p>
{#if e.noms.fr}<p class="fr">{e.noms.fr}</p>{/if}
<p class="iles muted">{e.iles.map((i) => NOM_ILE[i]).join(' · ')}</p>
<Manques entree={e} {locale} compact />
</a>
</li>
{/each}
</ul>
<h2 class="titre-danger">{t(locale, 'zerbaj.dangereuses')}</h2>
<ul class="grid liste">
{#each DANGEREUSES as e (e.id)}
<li>
<a class="card card--danger" href={chemin(locale, 'zerbaj', e.id)}>
<h3 lang="gcf">{e.noms.gcf}</h3>
<p class="muted latin">{e.taxon.scientifique}</p>
{#if e.noms.fr}<p class="fr">{e.noms.fr}</p>{/if}
{#if e.danger}
<p class="tag tag--danger">{t(locale, `fiche.danger.${e.danger.niveau}`)}</p>
{/if}
<Manques entree={e} {locale} compact />
</a>
</li>
{/each}
</ul>
</div>
<style>
.intro {
max-width: 52ch;
}
.liste {
list-style: none;
margin: 0 0 var(--space-5);
padding: 0;
}
.liste h3 {
margin-bottom: 0.25rem;
}
.latin {
font-style: italic;
font-size: 0.85rem;
margin-bottom: 0.25rem;
}
.fr {
margin-bottom: 0.25rem;
}
.iles {
font-size: 0.8rem;
margin: 0;
}
.titre-danger {
margin-top: var(--space-4);
}
.card--danger {
border-left-color: var(--rouge-oki);
}
</style>
@@ -0,0 +1,276 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import Manques from '$lib/components/Manques.svelte';
import QuestionKreyol from '$lib/components/QuestionKreyol.svelte';
import { chemin, t } from '$lib/i18n';
import { MOIS, NOM_ILE, mediasAffichables } from '$lib/data';
const { data } = $props();
const e = $derived(data.entree);
const locale = $derived(data.locale);
/** Ordre pédagogique du mode Kaché : du plus discret au plus évident. */
const ORDRE_INDICES = ['feuille', 'ecorce', 'port', 'coupe', 'fruit', 'fleur', 'latex', 'graine', 'usage_haie'] as const;
const indices = $derived(
ORDRE_INDICES.filter((k) => e.indices[k]).map((k) => ({ cle: k, texte: e.indices[k] as string }))
);
const images = $derived(mediasAffichables(e));
const jsonLd = $derived(
JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Thing',
name: e.noms.gcf,
alternateName: [e.noms.fr, e.noms.en, e.noms.es, e.taxon.scientifique].filter(Boolean),
description: e.danger?.resume ?? Object.values(e.indices)[0] ?? e.taxon.scientifique,
url: `https://ki-fwi.o-k-i.net${chemin(locale, 'zerbaj', e.id)}`,
sameAs: [
`https://www.wikidata.org/wiki/${e.taxon.wikidata}`,
`https://www.gbif.org/species/${e.taxon.gbif}`
],
additionalType: 'https://schema.org/Taxon',
inLanguage: 'gcf'
})
);
</script>
<Seo
{locale}
pathname={page.url.pathname}
titre={e.noms.gcf}
description={`${e.noms.gcf}${e.taxon.scientifique}. ${e.noms.fr ?? ''}`.trim()}
{jsonLd}
/>
<article class="container section">
<p class="fil"><a href={chemin(locale, 'zerbaj')}>{t(locale, 'fiche.retour')}</a></p>
<header class="entete">
<h1 lang="gcf">{e.noms.gcf}</h1>
<p class="latin">{e.taxon.scientifique}</p>
<p class="badges">
{#if e.danger}
<span class="tag tag--danger">{t(locale, `fiche.danger.${e.danger.niveau}`)}</span>
{/if}
<span class="tag" class:tag--ok={e.comestible}>
{e.comestible ? t(locale, 'fiche.comestible.oui') : t(locale, 'fiche.comestible.non')}
</span>
</p>
<p class="audio muted">{t(locale, 'fiche.audio-manquant')}</p>
</header>
{#if e.danger}
<section class="danger" aria-labelledby="danger-titre">
<h2 id="danger-titre">{t(locale, 'fiche.danger')}</h2>
<p class="resume">{e.danger.resume}</p>
{#if e.danger.signalement}
<h3>{t(locale, 'fiche.danger.signalement')}</h3>
<p>{e.danger.signalement}</p>
{/if}
{#if e.danger.conduite}
<h3>{t(locale, 'fiche.danger.conduite')}</h3>
<p>{e.danger.conduite}</p>
{/if}
</section>
{/if}
<QuestionKreyol entree={e} {locale} />
<section aria-labelledby="noms-titre">
<h2 id="noms-titre">{t(locale, 'fiche.autres-noms')}</h2>
<dl class="paires">
{#if e.noms.fr}<dt>Français</dt><dd>{e.noms.fr}</dd>{/if}
{#if e.noms.en}<dt>English</dt><dd lang="en">{e.noms.en}</dd>{/if}
{#if e.noms.es}<dt>Español</dt><dd lang="es">{e.noms.es}</dd>{/if}
<dt>{t(locale, 'fiche.taxon')}</dt>
<dd><em>{e.taxon.scientifique}</em></dd>
<dt>{t(locale, 'fiche.famille')}</dt>
<dd>{e.taxon.famille}</dd>
</dl>
<p class="muted petit">
{t(locale, 'fiche.verifie', { date: e.taxon.verifie_le })} —
<a href={`https://www.wikidata.org/wiki/${e.taxon.wikidata}`} rel="external noopener noreferrer" target="_blank">
{e.taxon.wikidata}
</a>
·
<a href={`https://www.gbif.org/species/${e.taxon.gbif}`} rel="external noopener noreferrer" target="_blank">
GBIF {e.taxon.gbif}
</a>
</p>
</section>
{#if indices.length}
<section aria-labelledby="indices-titre">
<h2 id="indices-titre">{t(locale, 'fiche.indices')}</h2>
<dl class="paires">
{#each indices as indice (indice.cle)}
<dt>{t(locale, `fiche.indice.${indice.cle}`)}</dt>
<dd>{indice.texte}</dd>
{/each}
</dl>
</section>
{/if}
<section aria-labelledby="iles-titre">
<h2 id="iles-titre">{t(locale, 'fiche.iles')}</h2>
<p>{e.iles.map((i) => NOM_ILE[i]).join(' · ')}</p>
</section>
{#if e.saison_mois?.length}
<section aria-labelledby="saison-titre">
<h2 id="saison-titre">{t(locale, 'fiche.saison')}</h2>
<ul class="mois">
{#each MOIS as nom, i (nom)}
<li class:actif={e.saison_mois?.includes(i + 1)}>
<span aria-hidden="true">{nom.slice(0, 3)}</span>
<span class="sr-only">{nom}{e.saison_mois?.includes(i + 1) ? '' : ' — non'}</span>
</li>
{/each}
</ul>
<p class="muted petit">{t(locale, 'fiche.saison-non-sourcee')}</p>
</section>
{/if}
<section aria-labelledby="medias-titre">
<h2 id="medias-titre">{t(locale, 'fiche.medias')}</h2>
{#if images.length === 0}
<p class="muted">{t(locale, 'fiche.aucune-image')}</p>
{:else}
<ul class="galerie">
{#each images as m (m.id)}
<li>
<figure>
<img src={m.fichier_local ?? ''} alt={m.description ?? e.noms.gcf} loading="lazy" />
<figcaption class="muted petit">
{m.auteur}{m.licence}
<a href={m.url_description} rel="external noopener noreferrer" target="_blank">source</a>
</figcaption>
</figure>
</li>
{/each}
</ul>
{/if}
</section>
{#if e.sources_factuelles.length}
<section aria-labelledby="sources-titre">
<h2 id="sources-titre">{t(locale, 'fiche.sources')}</h2>
<ul>
{#each e.sources_factuelles as s (s.url)}
<li>
<a href={s.url} rel="external noopener noreferrer" target="_blank">{s.titre}</a>
<span class="muted petit">({s.consulte_le})</span>
</li>
{/each}
</ul>
</section>
{/if}
<Manques entree={e} {locale} />
</article>
<style>
.fil {
font-size: 0.85rem;
}
.entete {
margin-bottom: var(--space-4);
}
.latin {
font-style: italic;
color: var(--muted);
margin-bottom: var(--space-2);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.audio {
font-size: 0.85rem;
}
section {
margin-bottom: var(--space-4);
}
.danger {
padding: var(--space-3);
background: color-mix(in srgb, var(--rouge-oki) 6%, transparent);
border: 1px solid var(--rouge-oki);
border-radius: var(--radius-sm);
}
.danger .resume {
font-weight: 600;
}
.paires {
display: grid;
grid-template-columns: minmax(8rem, auto) 1fr;
gap: 0.4rem var(--space-2);
margin: 0;
}
.paires dt {
font-family: var(--font-display);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--or-oki);
}
.paires dd {
margin: 0;
}
.petit {
font-size: 0.8rem;
}
.mois {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
list-style: none;
margin: 0 0 var(--space-2);
padding: 0;
}
.mois li {
padding: 0.35rem 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--muted);
}
.mois li.actif {
background: color-mix(in srgb, var(--or-oki) 15%, transparent);
border-color: var(--or-oki);
color: var(--blanc-creme);
}
.galerie {
display: grid;
gap: var(--space-2);
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.galerie figure {
margin: 0;
}
</style>
@@ -0,0 +1,23 @@
import { error } from '@sveltejs/kit';
import { ENTREES, entreeParId } from '$lib/data';
import { LOCALE_DEFAUT, LOCALES, type Locale } from '$lib/i18n';
import type { EntryGenerator, PageLoad } from './$types';
/**
* Toutes les fiches, dans toutes les langues, sont prérendues.
* Le crawler ne peut pas deviner les identifiants : on les lui donne.
*/
export const entries: EntryGenerator = () =>
LOCALES.flatMap((l) =>
ENTREES.map((e) => ({ locale: l === LOCALE_DEFAUT ? '' : l, id: e.id }))
);
export const load: PageLoad = ({ params }) => {
const entree = entreeParId(params.id);
if (!entree) error(404, 'Fiche introuvable');
return {
entree,
locale: (params.locale ?? LOCALE_DEFAUT) as Locale
};
};
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { t } from '$lib/i18n';
// Cible de `navigateFallback` du service worker. Un état hors-ligne designé
// plutôt qu'une page d'erreur du navigateur (playbook P5).
const locale = 'fr' as const;
</script>
<svelte:head>
<title>Hors ligne — KI FWI SA YÉ ?</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="container section centre">
<span class="flag-chip" aria-hidden="true"></span>
<h1>{t(locale, 'offline.titre')}</h1>
<p>{t(locale, 'offline.texte')}</p>
<p><a class="btn" href="/">{t(locale, 'erreur.retour')}</a></p>
</div>
<style>
.centre {
text-align: center;
display: grid;
justify-items: center;
gap: var(--space-1);
}
</style>
+48
View File
@@ -0,0 +1,48 @@
# KI FWI SA YÉ ? — Apache / o2switch
# Site statique intégralement prérendu. Aucun script inline, aucun domaine tiers :
# la CSP peut donc rester stricte sans exception (playbook §2.10).
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; media-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()"
# Le service worker ne doit jamais être servi depuis le cache HTTP,
# sinon une mise à jour du jeu peut ne jamais atteindre l'appareil.
<FilesMatch "sw\.js$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType image/avif "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType audio/ogg "access plus 1 year"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(woff2|avif|webp|opus)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule>
AddType application/manifest+json .webmanifest
AddType audio/ogg .opus
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</IfModule>
# 404 statique et autonome : lisible même si tout le reste échoue.
ErrorDocument 404 /404.html
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Page introuvable — KI FWI SA YÉ ?</title>
<meta name="robots" content="noindex" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
/* Autonome : aucune dépendance, aucune police à charger. Cette page doit
s'afficher même quand tout le reste du site est cassé. */
:root {
--noir: #0d0d0d;
--creme: #fff8e7;
--or: #fdb813;
--vert: #00d66c;
--rouge: #ff1654;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-content: center;
gap: 1rem;
padding: 2rem;
background: var(--noir);
color: var(--creme);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
text-align: center;
}
.flag {
height: 6px;
width: 220px;
margin: 0 auto;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
var(--or) 25% 50%,
var(--vert) 50% 75%,
var(--rouge) 75% 100%
);
}
h1 {
margin: 0;
font-size: clamp(1.6rem, 6vw, 2.6rem);
text-transform: uppercase;
letter-spacing: -0.01em;
}
p {
margin: 0;
opacity: 0.75;
max-width: 44ch;
}
a {
display: inline-block;
margin-top: 0.5rem;
min-height: 44px;
padding: 0.7em 1.4em;
color: var(--or);
border: 2px solid var(--or);
border-radius: 3px;
text-decoration: none;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.03em;
}
a:hover,
a:focus-visible {
background: var(--or);
color: var(--noir);
}
</style>
</head>
<body>
<div class="flag"></div>
<h1>Page introuvable</h1>
<p>Cette page nexiste pas.</p>
<a href="/">Retour à laccueil</a>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; media-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()
/_app/immutable/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
/sw.js
Cache-Control: no-cache
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="KI FWI SA YÉ ?">
<rect width="64" height="64" fill="#0D0D0D" />
<!-- Les quatre segments francs du drapeau panafricain, à la verticale. -->
<rect x="0" y="52" width="16" height="12" fill="#0D0D0D" />
<rect x="16" y="52" width="16" height="12" fill="#FDB813" />
<rect x="32" y="52" width="16" height="12" fill="#00D66C" />
<rect x="48" y="52" width="16" height="12" fill="#FF1654" />
<!-- Arche or, angles nets : aucune forme organique (charte §1.4). -->
<path d="M14 46V22a18 18 0 0 1 36 0v24h-9V22a9 9 0 0 0-18 0v24z" fill="#FDB813" />
<rect x="28" y="30" width="8" height="8" fill="#00D66C" />
</svg>

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "KI FWI SA YÉ ?",
"short_name": "KI FWI",
"description": "Reconnaître les fruits, les arbres et les plantes des Antilles. En kréyòl d'abord.",
"lang": "gcf",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0D0D0D",
"theme_color": "#0D0D0D",
"categories": ["education", "games"],
"icons": [
{ "src": "/icone-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icone-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icone-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+14
View File
@@ -0,0 +1,14 @@
/**
* Enregistrement du service worker par fichier statique, avec chemin ABSOLU.
*
* Playbook §2.9 : l'injection du plugin n'atteint pas le HTML prérendu, et le
* registerSW.js généré utilise un chemin relatif `./sw.js` qui casse sur les routes
* imbriquées comme /fr/zerbaj/kowosol/.
*/
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {
/* Hors ligne au premier chargement, ou contexte non sécurisé : sans conséquence. */
});
});
}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://ki-fwi.o-k-i.net/sitemap.xml
+19
View File
@@ -0,0 +1,19 @@
/**
* Anti-FOUC. Fichier externe et non inline : la CSP du projet est `script-src 'self'`.
* Pose les classes d'état avant le premier rendu pour éviter le flash de thème.
*/
(function () {
var racine = document.documentElement;
racine.classList.remove('no-js');
racine.classList.add('js');
try {
var choix = localStorage.getItem('oki-theme');
if (choix === 'light') racine.classList.add('light-theme');
else if (!choix && window.matchMedia('(prefers-color-scheme: light)').matches) {
racine.classList.add('light-theme');
}
} catch (e) {
/* localStorage indisponible (navigation privée stricte) : on garde le thème sombre. */
}
})();
+32
View File
@@ -0,0 +1,32 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
// Prérendu intégral : le site est un paquet de fichiers, pas un serveur.
// C'est ce qui permet le hors-ligne, l'hébergement statique et le zéro-tiers.
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true
}),
prerender: {
handleHttpError: 'fail',
handleMissingId: 'fail'
},
paths: {
// Playbook §5b.5 : sans cela, base vaut ".." au prerender et casse toutes
// les comparaisons de pathname dans le HTML prérendu.
relative: false
},
alias: {
$data: 'src/lib/data'
}
}
};
export default config;
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* check-i18n.mjs — inventaire des chaînes d'interface non traduites.
*
* Le kréyòl est la langue principale du projet, et l'interface est écrite en français.
* C'est une contradiction assumée : aucun agent de code n'écrit de kréyòl (ADR 0005),
* et un catalogue rempli par un automate serait pire que vide.
*
* Ce n'est donc pas une dette technique mais une tâche de participation : ce script
* produit la liste, phrase par phrase, avec la version française à traduire.
*
* Sortie : docs/i18n-a-traduire.md
*/
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const I18N = join(ROOT, 'src', 'lib', 'i18n');
const LOCALES = ['gcf', 'en'];
/** Regroupe les clés par préfixe pour que la traduction se fasse par écran, pas en vrac. */
const SECTIONS = {
site: 'Identité du site',
nav: 'Navigation',
home: 'Accueil',
zerbaj: 'Encyclopédie',
fiche: 'Fiche despèce',
manques: 'Ce qui manque',
kreyol: 'Questions de langue',
kontribiye: 'Participation',
jeu: 'Le jeu',
patouche: 'Module « Pa touché »',
footer: 'Pied de page',
credits: 'Crédits',
erreur: 'Erreurs',
offline: 'Hors ligne',
i18n: 'Bandeau de traduction'
};
async function main() {
const fr = JSON.parse(await readFile(join(I18N, 'fr.json'), 'utf8'));
const cles = Object.keys(fr).filter((c) => !c.startsWith('$'));
const l = [
'# Interface à traduire',
'',
`> Généré par \`npm run check:i18n\` le ${new Date().toISOString().slice(0, 10)}.`,
'> Sortie de script : ne pas éditer à la main.',
'',
'Le kréyòl est la langue principale du projet, et pourtant ce site affiche du français.',
'Ce nest pas un oubli : **aucun agent de code n’écrit de kréyòl** (ADR 0005), et un',
'catalogue rempli par une machine serait pire que vide — il figerait des tournures que',
'personne nemploie.',
'',
'La traduction est donc une contribution, au même titre quune photographie ou un',
'enregistrement. Marche à suivre : [`kreyol-participatif.md`](kreyol-participatif.md).',
''
];
const resume = [];
for (const locale of LOCALES) {
const cat = JSON.parse(await readFile(join(I18N, `${locale}.json`), 'utf8'));
const manquantes = cles.filter((c) => !cat[c]);
const couverture = Math.round(((cles.length - manquantes.length) / cles.length) * 100);
resume.push({ locale, manquantes: manquantes.length, total: cles.length, couverture });
l.push(`## ${locale === 'gcf' ? 'Kréyòl gwadloupéyen' : 'English'}${couverture} % traduit`, '');
if (!manquantes.length) {
l.push('_Rien à traduire._', '');
continue;
}
l.push(`**${manquantes.length} phrases sur ${cles.length}.**`, '');
const parSection = new Map();
for (const c of manquantes) {
const prefixe = c.split('.')[0];
if (!parSection.has(prefixe)) parSection.set(prefixe, []);
parSection.get(prefixe).push(c);
}
for (const [prefixe, liste] of parSection) {
l.push(`### ${SECTIONS[prefixe] ?? prefixe} (${liste.length})`, '');
l.push('| clé | français | traduction |', '|---|---|---|');
for (const c of liste) {
const source = String(fr[c]).replace(/\|/g, '\\|');
l.push(`| \`${c}\` | ${source} | |`);
}
l.push('');
}
}
l.push(
'## Comment rendre une traduction',
'',
'Remplir la colonne « traduction », ou envoyer la liste par tout moyen — un message,',
'un fichier, une note vocale. La forme na aucune importance ; la relecture par un',
'locuteur en a.',
'',
'Les valeurs traduites se reportent dans `src/lib/i18n/gcf.json`. Une valeur `null`',
'se replie automatiquement sur le français, avec lattribut `lang` correct : rien ne',
'casse tant que la traduction manque.',
''
);
await writeFile(join(ROOT, 'docs', 'i18n-a-traduire.md'), l.join('\n'));
for (const r of resume) {
console.log(`${r.locale} : ${r.total - r.manquantes}/${r.total} traduites (${r.couverture} %)`);
}
console.log('\n docs/i18n-a-traduire.md');
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
},
"exclude": ["tools/**/*"]
}
+34
View File
@@ -0,0 +1,34 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { VitePWA } from 'vite-plugin-pwa';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit(),
// `VitePWA` directement, et non `@vite-pwa/sveltekit` : le playbook §2.9 impose
// l'enregistrement par fichier statique, l'intégration n'apporterait rien de plus
// et ajouterait une dépendance.
VitePWA({
registerType: 'autoUpdate',
// Enregistrement par fichier statique (playbook §2.9) : l'injection du plugin
// n'atteint pas le HTML prérendu et produit un chemin relatif cassé sur les
// routes imbriquées comme /fr/zerbaj/kowosol/.
injectRegister: false,
manifest: false,
workbox: {
globPatterns: ['**/*.{js,css,html,woff2,svg,webp,avif,png,opus}'],
navigateFallback: '/offline/index.html',
navigateFallbackDenylist: [/\.(?:png|jpg|jpeg|svg|webp|avif|woff2|opus)$/],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
}
})
],
server: { fs: { strict: true } },
build: {
// Un budget se surveille : Vite prévient au-delà, on ne le découvre pas en production.
chunkSizeWarningLimit: 170
}
});