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
+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>