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
+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);
}