feat(oki): intro publique, chimen, PWA offline, contribuer, kréyòl, ton OKI

- Landing : intro « Pourquoi ce site ? » (Chat Control expliqué au grand
  public) + sommaire-chimen en 3 arcs (Fondations 🟢 / Reprendre le
  contrôle 🟡 / Autodéfense avancée 🔴) avec compte d'outils par catégorie
- PWA : service worker offline-first (précache coquille, network-first
  avec repli cache pour les fiches consultées), badge hors-ligne discret
- Page /contribuer (3 voies + forge labola) + template d'issue Forgejo
  .gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml ; liens GitHub → labola.o-k-i.net
- Fiches jumelles créoles : chargeur content/outils/gcf/, affichage selon
  la langue avec bandeaux « an chantyé » / « póko tradui », 6 pilotes
  (signal, protonmail, brave, bitwarden, ublock, quad9) — À FAIRE RELIRE
  par un locuteur natif
- Ton OKI : les 6 mêmes fiches réécrites (tutoiement, registre
  militant-chaleureux) ; le script de migration n'écrase plus les fiches
  retravaillées (--force pour tout régénérer)
- UI : View Transitions (respect reduced-motion), aide clavier « ? »
  (dialog natif, raccourcis / h a), progression locale « jès fèt »
  (localStorage uniquement, compteur visible après le premier geste)
- docs/DEPLOYMENT.md : guide VPS (Caddy, nginx, YunoHost, Docker, rsync)
- Audits : axe-core 0 violation (5 pages), Lighthouse 99-98/100/100/100 ;
  corrections contraste monogrammes + ordre des titres
- Budgets : JS 46 Ko gzip, CSS 3,5 Ko, 59 pages prérendues

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sucupira
2026-07-10 13:38:43 -04:00
parent db5a997e50
commit b13a1e7a31
38 changed files with 1285 additions and 147 deletions
+51
View File
@@ -0,0 +1,51 @@
name: "Nouvo zouti — Proposer un outil"
description: "Pwopozé on zouti lib pou lis-la / Proposer un outil libre pour l'annuaire"
title: "[zouti] "
labels:
- "nouvo-zouti"
body:
- type: input
id: nom
attributes:
label: "Non a zouti-la / Nom de l'outil"
placeholder: "ex. Signal"
validations:
required: true
- type: input
id: url
attributes:
label: "Sit ofisyèl / Site officiel"
placeholder: "https://…"
validations:
required: true
- type: input
id: licence
attributes:
label: "Licence (identifiant SPDX si tu le connais)"
placeholder: "ex. AGPL-3.0"
- type: dropdown
id: difficulte
attributes:
label: "Difikilté / Difficulté d'installation"
options:
- "🟢 Débitan / Débutant"
- "🟡 Entèmédia / Intermédiaire"
- "🔴 Avansé / Avancé"
validations:
required: true
- type: textarea
id: pourquoi
attributes:
label: "Poukisa i konté ? / Pourquoi il compte ?"
description: "En quoi cet outil sert la souveraineté numérique ? Qui devrait s'en servir ?"
validations:
required: true
- type: checkboxes
id: verifs
attributes:
label: "Vérifications"
options:
- label: "L'outil est libre / open-source (code source publié)"
required: true
- label: "J'ai vérifié qu'il n'est pas déjà dans l'annuaire"
required: true
+2 -2
View File
@@ -61,7 +61,7 @@
}, },
{ {
"slug": "ia", "slug": "ia",
"title": "\"L'IA conversationnelle\"", "title": "L'IA conversationnelle",
"order": 15, "order": 15,
"part": 9 "part": 9
}, },
@@ -85,7 +85,7 @@
}, },
{ {
"slug": "os", "slug": "os",
"title": "\"Systèmes d'exploitation libres\"", "title": "Systèmes d'exploitation libres",
"order": 19, "order": 19,
"part": 12 "part": 12
} }
+6 -16
View File
@@ -14,30 +14,20 @@ lang: "fr"
## what ## what
Un coffre-fort à mots de passe qui les mémorise, les génère et les remplit pour vous, sur tous Un coffre-fort à mots de passe qui les mémorise, les génère et les remplit pour toi, sur tous tes appareils.
vos appareils.
## why ## why
Gratuit, audité, multiplateforme, et chiffré de bout en bout. Vous n'avez plus qu'un seul mot de Gratuit, audité, multiplateforme, chiffré de bout en bout. Tu n'as plus qu'un seul mot de passe fort à retenir — et le jour où tu veux ne dépendre de personne, il est <b>auto-hébergeable</b> (via Vaultwarden).
passe fort à retenir. Il est même <b>auto-hébergeable</b> (via Vaultwarden) pour ne dépendre de
personne.
## who ## who
Tout le monde. C'est le point de départ de toute la sécurité : un mot de passe unique et fort Tout le monde. C'est la fondation de toute ta sécurité : un mot de passe unique et fort par service, sans effort de mémoire.
par service.
## install ## install
<ol> <ol>
<li> <li>Crée un compte sur bitwarden.com et choisis un mot de passe maître <b>long</b> — une phrase entière. Ne l'oublie jamais : il n'est pas récupérable, et c'est voulu.</li>
Créez un compte sur bitwarden.com et choisissez un mot de passe maître <b>long</b> (une <li>Installe l'extension de navigateur et l'application mobile.</li>
phrase). Ne l'oubliez jamais : il n'est pas récupérable. <li>Au fil de tes connexions, laisse Bitwarden enregistrer puis remplacer tes vieux mots de passe par des mots de passe générés.</li>
</li>
<li>Installez l'extension de navigateur et l'application mobile.</li>
<li>
Au fil de vos connexions, laissez Bitwarden enregistrer puis remplacer vos anciens mots de
passe par des mots de passe générés.
</li>
</ol> </ol>
+6 -17
View File
@@ -14,31 +14,20 @@ lang: "fr"
## what ## what
Un navigateur basé sur Chromium qui bloque publicités et traqueurs par défaut, avec en prime une Un navigateur basé sur Chromium qui bloque publicités et traqueurs par défaut avec en prime une fenêtre privée connectée à Tor.
fenêtre privée connectée à Tor.
## why ## why
C'est l'option la plus simple pour quelqu'un qui vient de Chrome : même ergonomie, mais sans le C'est le changement le plus indolore quand tu viens de Chrome : même ergonomie, sans le pistage. Il bloque les pubs YouTube nativement, propose une visio chiffrée (Brave Talk, basé sur Jitsi) et un accès Tor en un clic — pratique pour récupérer un site bloqué.
pistage. Il bloque les pubs YouTube nativement, propose une visio chiffrée (Brave Talk, basée
sur Jitsi) et un accès Tor en un clic, pratique pour récupérer un site bloqué.
## who ## who
Les débutants qui veulent un changement indolore depuis Chrome. (Ignorez les fonctions Celles et ceux qui veulent quitter Chrome sans rien réapprendre. (Les fonctions crypto et pubs récompensées ? Ignore-les, elles se désactivent.)
crypto/pubs récompensées si elles ne vous intéressent pas.)
## install ## install
<ol> <ol>
<li> <li>Installe Brave depuis <b>brave.com</b>.</li>
Installez Brave depuis <b>brave.com</b>. <li>Règle les « Boucliers » (Shields) sur « agressif », et désactive Brave Rewards si tu ne veux pas de pubs.</li>
</li> <li>Pour Tor : menu &gt; « Nouvelle fenêtre privée avec Tor », puis attends « Tor connecté ».</li>
<li>
Réglez les « Boucliers » (Shields) sur « agressif », et désactivez Brave Rewards si vous ne
voulez pas de pubs.
</li>
<li>
Pour Tor : menu &gt; « Nouvelle fenêtre privée avec Tor », puis attendez « Tor connecté ».
</li>
</ol> </ol>
+25
View File
@@ -0,0 +1,25 @@
---
name: "Bitwarden"
slug: "bitwarden"
lang: "gcf"
---
## what
On kòf a modpas ki ka sonjé-yo, jénéré-yo é ranpli-yo pou vou, asi tout apawèy a-w.
## why
Gratis, odité, miltiplatfòwm, chifré bout-an-bout. Ou ni yenki on sèl modpas fò pou sonjé — é jou ou vlé pa dépann di pèsonn, ou pé <b>oto-ébèjé'y</b> (èvè Vaultwarden).
## who
Tout moun. Sé fondasyon a tout sékirité a-w : on modpas inik é fò pou chak sèwvis, san fòsé mémwa a-w.
## install
<ol>
<li>Kréyé on kont asi bitwarden.com é chwazi on modpas mèt <b>long</b> — on fraz antyé. Pa janmé oublié'y : i pa rékipérab, é sé fè èsprè.</li>
<li>Enstalé èkstansyon navigatè-la é aplikasyon mobil-la.</li>
<li>O fi a konèksyon a-w, kité Bitwarden anrèjistré é apré ranplasé vyé modpas a-w pa modpas jénéré.</li>
</ol>
+25
View File
@@ -0,0 +1,25 @@
---
name: "Brave"
slug: "brave"
lang: "gcf"
---
## what
On navigatè basé asi Chromium ki ka bloké piblisité é trakè pa défo — èvè an plis on fenèt privé konèkté asi Tor.
## why
Sé chanjman pi dous-la lè ou ka sòti an Chrome : menm èwgonomi, san pistaj-la. I ka bloké pib YouTube dirèk, i ka pwopozé on vizyo chifré (Brave Talk, basé asi Jitsi) é on aksè Tor an on klik — pratik pou ratrapé on sit bloké.
## who
Moun ki vlé kité Chrome san riaprann ayen. (Fonksyon crypto é pib rékonpansé ? Inyoré-yo, yo ka tenn.)
## install
<ol>
<li>Enstalé Brave dépi <b>brave.com</b>.</li>
<li>Réglé « Bouklyé »-la (Shields) asi « agrésif », é tenn Brave Rewards si ou pa vlé pib.</li>
<li>Pou Tor : mèni &gt; « Nouvo fenèt privé èvè Tor », apré atann « Tor konèkté ».</li>
</ol>
+26
View File
@@ -0,0 +1,26 @@
---
name: "Proton Mail"
slug: "protonmail"
lang: "gcf"
---
## what
On bwèt imèl chifré ki ka ranplasé Gmail oben Outlook — é kont pivo a tout ékosistèm Proton-la.
## why
Basé an Swis, andéwò a jiridiksyon « Five Eyes »-la. Konpatib OpenPGP : chifraj-la otomatik ant kont Proton é lòt sèwvis PGP. Kouryé a-w, sé korèspondans a-w — pa on matyè prèmyé pou piblisité.
## who
Tout moun. Sé pi bon pòt pou sòti an Google : louvè kont-la, rès a suit-la (ajanda, drive, VPN) ka suiv tousèl.
## install
<ol>
<li>Kréyé on kont asi <b>proton.me</b> (òf gratis-la ka sifi pou koumansé).</li>
<li>Rapatriyé vyé mésaj é kontak a-w èvè <b>Easy Switch</b> (entégré).</li>
<li>Mèt on rédirèksyon dépi Gmail é prévien kontak a-w di nouvo adrès a-w.</li>
<li>Enstalé aplikasyon mobil-la, limé <b>doub otantifikasyon-la</b> — é Proton Bridge si ou ka rété asi Thunderbird.</li>
</ol>
+24
View File
@@ -0,0 +1,24 @@
---
name: "Quad9"
slug: "quad9"
lang: "gcf"
---
## what
On rézolvè DNS chifré, swis é san bi likratif, ki ka bloké an plis domèn malfézan yo ja konnèt.
## why
Founisè aksè a-w ka vwè chak sit ou ka vizité gras a DNS-la. Quad9 ka chifré dimann-lasa yo, i pa ka gadé adrès IP a-w, é i ka pwotéjé-w kont lamèkantri an menm tan. On réglaj « fè'y é oublié'y ».
## who
Tout moun — sitou si ou vlé on vré gany san enstalé ayen anplis.
## install
<ol>
<li>Asi Android : Réglaj &gt; Rézo &gt; <b>DNS privé</b> &gt; matjé <b>dns.quad9.net</b>.</li>
<li>Asi iPhone/Mac : enstalé profil DoH-la dépi quad9.net. Asi PC : konfiguré DNS-over-HTTPS an navigatè-la oben an sistèm-la.</li>
</ol>
+26
View File
@@ -0,0 +1,26 @@
---
name: "Signal"
slug: "signal"
lang: "gcf"
---
## what
On mésajri chifré pou tèks, apèl, vizyo é group a-w. Sé ranplasan dirèk a WhatsApp — menm senplisité, san Meta dèyè.
## why
Chifraj bout-an-bout pa défo, san bi likratif, protokòl odité ki ka sèwvi référans toupatou. Prèské pon métadoné pa ka rété, é ékip-la ja di : yo ké kité Léwòp olyé yo enstalé on skannaj. Chak moun ou ka fè pasé, sé on konvèsasyon anplis ki ka sòti an survéyans-la.
## who
Tout moun, jòdi-la menm. Sé prèmyé jès-la, é pi itil-la : koumansé èvè group a fanmi a-w. Yenki on kontrent : on niméwo téléfòn — ou pé séré'y dèyè on non itilizatè.
## install
<ol>
<li>Enstalé dépi <b>signal.org</b> (App Store, Google Play, oben APK dirèk asi Android).</li>
<li>Konfirmé niméwo a-w pa SMS, apré chwazi on kòd PIN.</li>
<li>Réglaj &gt; Konfidansyalité : kréyé on <b>non itilizatè</b> pou séré niméwo a-w, é limé <b>vèwou enskripsyon-la</b>.</li>
<li>Envité moun a-w — group fanmi-la avan. Asi òwdinatè, lyanné aplikasyon biwo-la èvè téléfòn a-w.</li>
</ol>
+24
View File
@@ -0,0 +1,24 @@
---
name: "uBlock Origin"
slug: "ublock"
lang: "gcf"
---
## what
Èkstansyon-la ki ka bloké piblisité, trakè é èskrip èspyon. Jès pi rantab a tout gid-lasa : dis ségonn, éfè imedya.
## why
Piblisité an liny sé prensipal chimen a survéyans é profilaj. Bloké'y, sé koupé pi gwo moso a pistaj-la — é an menm balan paj a-w ka chajé pi vit é fòfé data a-w ka respiré.
## who
Tout moun, asi Firefox kon asi navigatè Chromium. Si ou ka fè yenki on sèl jès jòdi-la, fè ta-lasa.
## install
<ol>
<li>Louvè magazen èkstansyon a navigatè a-w é chèché « uBlock Origin » (orijinal-la, pa Raymond Hill).</li>
<li>Enstalé'y. I ka travay lamenm, san pon réglaj.</li>
</ol>
+7 -17
View File
@@ -13,31 +13,21 @@ lang: "fr"
## what ## what
Une boîte mail chiffrée qui remplace Gmail ou Outlook, et le compte pivot de tout l'écosystème Une boîte mail chiffrée qui remplace Gmail ou Outlook et le compte pivot de tout l'écosystème Proton.
Proton.
## why ## why
Basé en Suisse, hors juridiction des « Five Eyes ». Compatible OpenPGP : chiffrement automatique Basée en Suisse, hors juridiction des « Five Eyes ». Compatible OpenPGP : le chiffrement est automatique entre comptes Proton et vers les autres services PGP. Ton courrier, c'est ta correspondance — pas une matière première publicitaire.
entre comptes Proton et vers les autres services PGP.
## who ## who
Tout le monde. C'est le meilleur point de départ pour quitter Google (voir section 05 pour le Tout le monde. C'est la meilleure porte de sortie de Google : ouvre le compte, le reste de la suite (agenda, drive, VPN) suit tout seul.
reste de la suite).
## install ## install
<ol> <ol>
<li> <li>Crée un compte sur <b>proton.me</b> (l'offre gratuite suffit largement pour commencer).</li>
Créez un compte sur <b>proton.me</b> (offre gratuite suffisante pour commencer). <li>Rapatrie tes anciens messages et contacts avec <b>Easy Switch</b> (intégré).</li>
</li> <li>Mets une redirection depuis Gmail et préviens tes contacts de ta nouvelle adresse.</li>
<li> <li>Installe l'application mobile, active la <b>double authentification</b> — et Proton Bridge si tu tiens à Thunderbird.</li>
Importez vos anciens messages et contacts avec <b>Easy Switch</b> (intégré).
</li>
<li>Mettez une redirection depuis Gmail et prévenez vos contacts de votre nouvelle adresse.</li>
<li>
Installez l'application mobile, activez la <b>double authentification</b>, et utilisez Proton
Bridge si vous tenez à Thunderbird.
</li>
</ol> </ol>
+5 -12
View File
@@ -13,26 +13,19 @@ lang: "fr"
## what ## what
Un résolveur DNS chiffré, suisse et à but non lucratif, qui bloque en plus les domaines Un résolveur DNS chiffré, suisse et à but non lucratif, qui bloque en plus les domaines malveillants connus.
malveillants connus.
## why ## why
Juridiction suisse, pas de conservation de votre adresse IP, protection contre l'hameçonnage et Ton fournisseur d'accès voit chaque site que tu visites via le DNS. Quad9 chiffre ces requêtes, ne conserve pas ton adresse IP, et te protège de l'hameçonnage au passage. Un réglage « fais-le et oublie-le ».
les logiciels malveillants. Un excellent réglage « installer et oublier ».
## who ## who
Tout le monde, en particulier ceux qui veulent la simplicité et une bonne juridiction. Tout le monde — surtout si tu veux un gain réel sans rien installer de nouveau.
## install ## install
<ol> <ol>
<li> <li>Sur Android : Réglages &gt; Réseau &gt; <b>DNS privé</b> &gt; saisis <b>dns.quad9.net</b>.</li>
Sur Android : Réglages &gt; Réseau &gt; <b>DNS privé</b> &gt; saisissez <b>dns.quad9.net</b>. <li>Sur iPhone/Mac : installe le profil DoH depuis quad9.net. Sur PC : configure le DNS-over-HTTPS dans le navigateur ou le système.</li>
</li>
<li>
Sur iPhone/Mac : installez le profil DoH depuis quad9.net. Sur PC : configurez le
DNS-over-HTTPS dans le navigateur ou le système.
</li>
</ol> </ol>
+7 -16
View File
@@ -14,30 +14,21 @@ lang: "fr"
## what ## what
Une messagerie chiffrée pour vos textos, appels, visios et groupes. C'est le remplaçant direct Une messagerie chiffrée pour tes textos, appels, visios et groupes. C'est le remplaçant direct de WhatsApp — aussi simple, sans Meta derrière.
de WhatsApp, en aussi simple.
## why ## why
Chiffrement de bout en bout par défaut, à but non lucratif, protocole audité qui sert de Chiffrement de bout en bout par défaut, à but non lucratif, protocole audité qui sert de référence mondiale. Presque aucune métadonnée gardée, et l'équipe a promis de quitter l'UE plutôt que d'installer un scan. Chaque proche que tu fais basculer, c'est une conversation de plus qui sort de la surveillance.
référence mondiale. Presque aucune métadonnée conservée, et l'équipe a promis de quitter l'UE
plutôt que d'installer un scan.
## who ## who
Tout le monde, dès aujourd'hui. C'est le premier geste et le plus utile. Seule contrainte : un Tout le monde, dès aujourd'hui. C'est le premier geste, et le plus utile : commence par ton groupe de famille. Seule contrainte : un numéro de téléphone — que tu peux cacher derrière un nom d'utilisateur.
numéro de téléphone, que l'on peut masquer derrière un nom d'utilisateur.
## install ## install
<ol> <ol>
<li> <li>Installe depuis <b>signal.org</b> (App Store, Google Play, ou l'APK direct sur Android).</li>
Installez depuis <b>signal.org</b> (App Store, Google Play, ou l'APK direct sur Android). <li>Confirme ton numéro par SMS, puis choisis un code PIN.</li>
</li> <li>Réglages &gt; Confidentialité : crée un <b>nom d'utilisateur</b> pour cacher ton numéro, et active le <b>verrou d'inscription</b>.</li>
<li>Confirmez votre numéro par SMS, puis choisissez un code PIN.</li> <li>Invite tes proches — le groupe famille d'abord. Sur ordinateur, lie l'application de bureau à ton téléphone.</li>
<li>
Réglages &gt; Confidentialité : créez un <b>nom d'utilisateur</b> pour cacher votre numéro, et
activez le <b>verrou d'inscription</b>.
</li>
<li>Invitez vos proches. Sur ordinateur, liez l'application de bureau à votre téléphone.</li>
</ol> </ol>
+5 -10
View File
@@ -13,24 +13,19 @@ lang: "fr"
## what ## what
L'extension de navigateur qui bloque publicités, traqueurs et scripts espions. Le geste le plus L'extension qui bloque publicités, traqueurs et scripts espions. Le geste le plus rentable de tout ce guide : dix secondes, effet immédiat.
rentable, en dix secondes.
## why ## why
La publicité en ligne est le principal vecteur de surveillance et de profilage. La bloquer coupe La publicité en ligne est le principal vecteur de surveillance et de profilage. La bloquer, c'est couper l'essentiel du pistage — et au passage tes pages chargent plus vite et ton forfait data respire.
l'essentiel du pistage, accélère les pages et réduit les risques de logiciels malveillants.
## who ## who
Tout le monde, sur Firefox comme sur les navigateurs Chromium. Tout le monde, sur Firefox comme sur les navigateurs Chromium. Si tu ne fais qu'un seul geste aujourd'hui, fais celui-là.
## install ## install
<ol> <ol>
<li> <li>Ouvre le magasin d'extensions de ton navigateur et cherche « uBlock Origin » (l'original, par Raymond Hill).</li>
Ouvrez le magasin d'extensions de votre navigateur et cherchez « uBlock Origin » (l'original, <li>Installe-le. Il travaille aussitôt, sans aucun réglage.</li>
par Raymond Hill).
</li>
<li>Installez-le. Il fonctionne aussitôt, sans réglage nécessaire.</li>
</ol> </ol>
+18 -4
View File
@@ -46,6 +46,17 @@ le contrat de validation (ADR-001).
- Exemples et localisation ancrés territoire — jamais de Lorem ipsum - Exemples et localisation ancrés territoire — jamais de Lorem ipsum
hexagonal (doctrine §4.2). hexagonal (doctrine §4.2).
### Fiches de référence pour le ton
Six fiches sont réécrites au registre OKI et servent de modèle : `signal`,
`protonmail`, `brave`, `bitwarden`, `ublock`, `quad9`. Avant d'écrire ou de
réviser une fiche, relis-en deux. Ce qui change par rapport au ton d'origine :
tutoiement systématique, le geste avant la technique (« dix secondes, effet
immédiat »), l'enjeu politique dit sans détour (« ta correspondance — pas une
matière première publicitaire »), et un ancrage collectif (« commence par ton
groupe de famille »). Le script de migration ne régénère jamais une fiche
existante (`--force` pour tout écraser) : les réécritures sont protégées.
## Créole guadeloupéen (gcf) ## Créole guadeloupéen (gcf)
- Les **micro-textes de l'interface** (boutons, filtres, états vides, marqueur - Les **micro-textes de l'interface** (boutons, filtres, états vides, marqueur
@@ -55,10 +66,13 @@ le contrat de validation (ADR-001).
- ⚠️ **Relecture par un locuteur natif requise** : les chaînes gcf actuelles - ⚠️ **Relecture par un locuteur natif requise** : les chaînes gcf actuelles
sont un premier jet à valider avant mise en production. La graphie de sont un premier jet à valider avant mise en production. La graphie de
référence à trancher (GEREC-F ou autre) doit être notée ici une fois choisie. référence à trancher (GEREC-F ou autre) doit être notée ici une fois choisie.
- La traduction intégrale des fiches est un chantier éditorial séparé : - Chaque fiche peut avoir sa **jumelle créole** : `content/outils/gcf/<slug>.md`
le format prévu est un fichier jumeau `content/outils/gcf/<slug>.md` avec (frontmatter minimal : `name`, `slug`, `lang: "gcf"` ; mêmes sections
`lang: "gcf"` (le chargeur l'ignorera tant que la locale de contenu n'est `## what/why/who/install`). Elle s'affiche automatiquement quand la personne
pas activée). choisit Kréyòl, avec un bandeau « tradiksyon an chantyé » ; sans jumelle,
la version française s'affiche avec un bandeau « póko tradui ». Six jumelles
pilotes existent (mêmes slugs que les fiches de référence ci-dessus) —
**premier jet non relu**, à valider avant d'en faire des modèles.
- Termes fixés : « zouti » (outil), « Ou pa manké ayen » (fin de liste), - Termes fixés : « zouti » (outil), « Ou pa manké ayen » (fin de liste),
« Chwazi zouti ou » (héros), niveaux « Débitan / Entèmédia / Avansé ». « Chwazi zouti ou » (héros), niveaux « Débitan / Entèmédia / Avansé ».
+140
View File
@@ -0,0 +1,140 @@
# Déployer lage-chat-control sur un VPS
Le site est **100 % statique** (ADR-001) : le déploiement consiste à poser le
contenu du dossier `frontend/build/` derrière un serveur web. Pas de base de
données, pas de service Node à maintenir, pas de tâche cron. Un miroir du site
= une copie du dossier.
## 0. Prérequis
- Un VPS (1 vCPU / 512 Mo suffisent très largement) sous Debian/Ubuntu
- Un nom de domaine pointant sur le VPS (enregistrement A/AAAA)
- En local : Node ≥ 22.12 et pnpm (le build se fait **sur ta machine**,
le VPS n'a besoin ni de Node ni de pnpm)
## 1. Construire le site
```sh
git clone https://labola.o-k-i.net/cyber-mawonaj/lage-chat-control.git
cd lage-chat-control/frontend
pnpm install
pnpm build # produit frontend/build/ (~60 pages statiques)
```
## 2. Option A — Caddy (recommandé : TLS automatique)
```sh
# sur le VPS
sudo apt install caddy
sudo mkdir -p /srv/lage-chat-control
```
`/etc/caddy/Caddyfile` :
```caddy
exemple.o-k-i.net {
root * /srv/lage-chat-control
file_server
encode zstd gzip
# SvelteKit adapter-static : pages pré-générées + fallback
try_files {path} {path}.html {path}/index.html /404.html
header {
# le service worker doit pouvoir se mettre à jour
Cache-Control "no-cache" /service-worker.js
# assets immuables (noms hachés)
Cache-Control "public, max-age=31536000, immutable" /_app/immutable/*
# en-têtes de sécurité
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
# CSP stricte : le site n'a aucune dépendance externe
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'"
}
}
```
Le TLS (Let's Encrypt) est automatique, rien d'autre à faire.
## 3. Option B — nginx
```sh
sudo apt install nginx certbot python3-certbot-nginx
```
`/etc/nginx/sites-available/lage-chat-control` :
```nginx
server {
server_name exemple.o-k-i.net;
root /srv/lage-chat-control;
index index.html;
# pages pré-générées : /outils → outils.html, fallback 404
location / {
try_files $uri $uri.html $uri/index.html /404.html;
}
location /_app/immutable/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /service-worker.js {
add_header Cache-Control "no-cache";
}
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'" always;
gzip on;
gzip_types text/html text/css application/javascript application/json image/svg+xml;
}
```
```sh
sudo ln -s /etc/nginx/sites-available/lage-chat-control /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d exemple.o-k-i.net # TLS
```
## 4. Publier (et republier)
```sh
# depuis ta machine, après chaque `pnpm build`
rsync -avz --delete frontend/build/ vps:/srv/lage-chat-control/
```
C'est l'intégralité du « pipeline de déploiement ». Pour automatiser :
un runner Forgejo Actions sur labola peut exécuter build + rsync à chaque
push sur `main` (à ajouter quand le besoin se présente, pas avant).
## 5. Option C — YunoHost
Le site s'installe avec l'app **`my_webapp`** (site statique) :
1. Installe `my_webapp` depuis le catalogue, choisis le domaine/chemin.
2. Copie le contenu de `frontend/build/` dans le dossier `www/` de l'app
(SFTP ou rsync avec l'utilisateur dédié créé par l'app).
3. Dans la config nginx avancée de l'app, ajoute le bloc `try_files` ci-dessus.
YunoHost gère TLS, HSTS et le renouvellement des certificats.
## 6. Option D — Docker
Le `Dockerfile` et `docker/nginx.conf` du dépôt (hérités du site Astro)
servent déjà un site statique durci ; adapter le chemin de copie vers
`frontend/build/` si tu veux cette voie. Utile pour l'infra OKI si tout
le reste tourne déjà en conteneurs — sinon les options A/B sont plus simples.
## 7. Vérifier après mise en ligne
- [ ] `curl -sI https://exemple.o-k-i.net | grep -i content-security` → CSP présente
- [ ] La page `/outils` répond (et `/outils?cat=messagerie` filtre)
- [ ] DevTools > Application > Service worker : enregistré ; passer hors-ligne
et recharger une fiche déjà visitée → elle s'affiche
- [ ] observatory.mozilla.org : note A/A+ attendue avec les en-têtes ci-dessus
- [ ] Aucune requête sortante vers un domaine tiers (onglet Réseau)
+4 -1
View File
@@ -10,14 +10,17 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"migrate": "node scripts/migrate-tools.mjs" "migrate": "node scripts/migrate-tools.mjs",
"audit:a11y": "node scripts/audit-a11y.mjs"
}, },
"engines": { "engines": {
"node": ">=22.12" "node": ">=22.12"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@fontsource/atkinson-hyperlegible": "^5.2.8", "@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9", "@fontsource/fraunces": "^5.2.9",
"@playwright/test": "1.61.1",
"@sveltejs/adapter-static": "^3.0.0", "@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
+57
View File
@@ -8,12 +8,18 @@ importers:
.: .:
devDependencies: devDependencies:
'@axe-core/playwright':
specifier: ^4.12.1
version: 4.12.1(playwright-core@1.61.1)
'@fontsource/atkinson-hyperlegible': '@fontsource/atkinson-hyperlegible':
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.2.8 version: 5.2.8
'@fontsource/fraunces': '@fontsource/fraunces':
specifier: ^5.2.9 specifier: ^5.2.9
version: 5.2.9 version: 5.2.9
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
'@sveltejs/adapter-static': '@sveltejs/adapter-static':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.10(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.4)(vite@7.3.6(@types/node@26.1.1)))(svelte@5.56.4)(typescript@5.9.3)(vite@7.3.6(@types/node@26.1.1))) version: 3.0.10(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.4)(vite@7.3.6(@types/node@26.1.1)))(svelte@5.56.4)(typescript@5.9.3)(vite@7.3.6(@types/node@26.1.1)))
@@ -44,6 +50,11 @@ importers:
packages: packages:
'@axe-core/playwright@4.12.1':
resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==}
peerDependencies:
playwright-core: '>= 1.0.0'
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -222,6 +233,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -432,6 +448,10 @@ packages:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
axe-core@4.12.1:
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
engines: {node: '>=4'}
axobject-query@4.1.0: axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -480,6 +500,11 @@ packages:
picomatch: picomatch:
optional: true optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -527,6 +552,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'} engines: {node: '>=12'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.16: postcss@8.5.16:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -636,6 +671,11 @@ packages:
snapshots: snapshots:
'@axe-core/playwright@4.12.1(playwright-core@1.61.1)':
dependencies:
axe-core: 4.12.1
playwright-core: 1.61.1
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
optional: true optional: true
@@ -737,6 +777,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@rollup/rollup-android-arm-eabi@4.62.2': '@rollup/rollup-android-arm-eabi@4.62.2':
@@ -877,6 +921,8 @@ snapshots:
aria-query@5.3.1: {} aria-query@5.3.1: {}
axe-core@4.12.1: {}
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
chokidar@4.0.3: chokidar@4.0.3:
@@ -930,6 +976,9 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -959,6 +1008,14 @@ snapshots:
picomatch@4.0.5: {} picomatch@4.0.5: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.16: postcss@8.5.16:
dependencies: dependencies:
nanoid: 3.3.15 nanoid: 3.3.15
+24
View File
@@ -0,0 +1,24 @@
// Audit axe-core des pages clés servies sur localhost:4173
import { chromium } from '@playwright/test'
import { AxeBuilder } from '@axe-core/playwright'
const PAGES = ['/', '/outils', '/outils/signal', '/outils/tor', '/contribuer']
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
let total = 0
for (const path of PAGES) {
await page.goto(`http://localhost:4173${path}`, { waitUntil: 'networkidle' })
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze()
total += results.violations.length
console.log(`\n═══ ${path}${results.violations.length} violation(s)`)
for (const v of results.violations) {
console.log(` [${v.impact}] ${v.id}: ${v.help}`)
for (const n of v.nodes.slice(0, 3)) console.log(`${n.target.join(' ')}`)
}
}
await browser.close()
console.log(`\nTOTAL: ${total} violation(s)`)
process.exit(total > 0 ? 1 : 0)
+9 -2
View File
@@ -8,6 +8,7 @@
* Idempotent : relançable, écrase la sortie. Usage : pnpm migrate * Idempotent : relançable, écrase la sortie. Usage : pnpm migrate
*/ */
import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises' import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@@ -31,7 +32,7 @@ function parseFrontmatter(raw) {
if (i === -1) continue if (i === -1) continue
const key = line.slice(0, i).trim() const key = line.slice(0, i).trim()
let value = line.slice(i + 1).trim() let value = line.slice(i + 1).trim()
if (/^'.*'$/.test(value)) value = value.slice(1, -1) if (/^'.*'$/.test(value) || /^".*"$/.test(value)) value = value.slice(1, -1)
meta[key] = /^\d+$/.test(value) ? Number(value) : value meta[key] = /^\d+$/.test(value) ? Number(value) : value
} }
return [meta, raw.slice(m[0].length)] return [meta, raw.slice(m[0].length)]
@@ -105,6 +106,12 @@ for (const file of files) {
continue continue
} }
seen.add(slug) seen.add(slug)
// ne jamais écraser une fiche retravaillée à la main (--force pour tout régénérer)
const outPath = path.join(OUT, 'outils', `${slug}.md`)
if (existsSync(outPath) && !process.argv.includes('--force')) {
written++
continue
}
const dir = dirById.get(slug) const dir = dirById.get(slug)
const nameOverride = slot(block, 'name') const nameOverride = slot(block, 'name')
@@ -135,7 +142,7 @@ for (const file of files) {
.map(([key, html]) => `## ${key}\n\n${html}`) .map(([key, html]) => `## ${key}\n\n${html}`)
.join('\n\n') .join('\n\n')
await writeFile(path.join(OUT, 'outils', `${slug}.md`), `---\n${front}\n---\n\n${sections}\n`) await writeFile(outPath, `---\n${front}\n---\n\n${sections}\n`)
written++ written++
} }
} }
+39
View File
@@ -0,0 +1,39 @@
import { browser } from '$app/environment'
/**
* « Jès fèt » — les outils que la personne a adoptés.
* Effet Zeigarnik utilisé positivement (doctrine §1.2) : une progression
* choisie, pas un streak. Stocké uniquement sur l'appareil, jamais envoyé
* nulle part (pas de compte, pas de télémétrie — doctrine §3.3 : aucune
* métrique anxiogène ; le compteur n'apparaît qu'après le premier geste).
*/
const STORAGE_KEY = 'oki-jes'
function initial(): string[] {
if (!browser) return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as string[]) : []
} catch {
return []
}
}
const state = $state({ slugs: initial() })
function persist() {
if (browser) localStorage.setItem(STORAGE_KEY, JSON.stringify(state.slugs))
}
export function isAdopted(slug: string): boolean {
return state.slugs.includes(slug)
}
export function adoptedCount(): number {
return state.slugs.length
}
export function toggleAdopted(slug: string) {
state.slugs = isAdopted(slug) ? state.slugs.filter((s) => s !== slug) : [...state.slugs, slug]
persist()
}
@@ -0,0 +1,28 @@
<script lang="ts">
import { isAdopted, toggleAdopted } from '$lib/adopted.svelte'
import { t } from '$lib/i18n/index.svelte'
let { slug }: { slug: string } = $props()
let adopted = $derived(isAdopted(slug))
</script>
<!-- Amélioration progressive : sans JS le bouton n'apparaît pas du tout
(rendu identique, aucune fonction cassée). -->
<button type="button" class:adopted aria-pressed={adopted} onclick={() => toggleAdopted(slug)}>
{adopted ? t('adopted.marked') : t('adopted.mark')}
<span class="visually-hidden">{t('adopted.srHint')}</span>
</button>
<style>
button {
border: 2px solid var(--oki-vert);
background: transparent;
color: var(--oki-ink);
font-weight: 600;
}
button.adopted {
background: var(--oki-vert);
color: oklch(98% 0 0);
}
</style>
@@ -0,0 +1,31 @@
<script lang="ts">
import { browser } from '$app/environment'
import { t } from '$lib/i18n/index.svelte'
// Technologie périphérique (doctrine §3.5) : l'état réseau est une
// information non urgente — un badge discret, jamais une interruption.
let offline = $state(browser ? !navigator.onLine : false)
</script>
<svelte:window ononline={() => (offline = false)} onoffline={() => (offline = true)} />
{#if offline}
<p class="offline" role="status">
<strong>{t('offline.badge')}</strong>{t('offline.detail')}
</p>
{/if}
<style>
.offline {
font-size: var(--text-0);
color: var(--oki-ink-2);
background: var(--oki-surface-2);
border-block: 1px solid var(--oki-line);
padding: var(--space-2) var(--space-3);
text-align: center;
}
strong {
color: var(--accent-ink);
}
</style>
+3 -2
View File
@@ -7,8 +7,9 @@
// raccourci « / » desktop (doctrine §2.2 — le clavier est roi) // raccourci « / » desktop (doctrine §2.2 — le clavier est roi)
function onkeydown(e: KeyboardEvent) { function onkeydown(e: KeyboardEvent) {
if (e.key === '/' && document.activeElement !== input) { if (e.key === '/' && document.activeElement !== input) {
const target = e.target as HTMLElement const target = e.target
if (target.closest('input, textarea, [contenteditable]')) return if (target instanceof HTMLElement && target.closest('input, textarea, [contenteditable]'))
return
e.preventDefault() e.preventDefault()
input.focus() input.focus()
} }
@@ -0,0 +1,99 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { t } from '$lib/i18n/index.svelte'
// Le clavier est roi sur desktop (doctrine §2.2.3) : raccourcis découvrables
// via « ? », jamais porteurs d'information exclusive (tout est aussi dans la nav).
let dialog: HTMLDialogElement
const KEYS = [
{ key: '/', label: 'shortcuts.search' },
{ key: 'h', label: 'shortcuts.home' },
{ key: 'a', label: 'shortcuts.directory' },
{ key: '?', label: 'shortcuts.help' },
{ key: 'Échap', label: 'shortcuts.close' }
]
function onkeydown(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey) return
const target = e.target
if (target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable]'))
return
if (e.key === '?') {
e.preventDefault()
dialog.open ? dialog.close() : dialog.showModal()
} else if (e.key === 'h') {
goto('/')
} else if (e.key === 'a') {
goto('/outils')
}
}
</script>
<svelte:window {onkeydown} />
<!-- <dialog> natif : focus piégé, Échap géré par le navigateur, zéro ARIA maison -->
<dialog bind:this={dialog}>
<h2>{t('shortcuts.title')}</h2>
<dl>
{#each KEYS as { key, label } (key)}
<div>
<dt><kbd>{key}</kbd></dt>
<dd>{t(label)}</dd>
</div>
{/each}
</dl>
<form method="dialog">
<button>{t('shortcuts.close')}</button>
</form>
</dialog>
<style>
dialog {
border: 1px solid var(--oki-line);
border-top: 4px solid var(--oki-jaune);
border-radius: var(--radius-2);
background: var(--oki-surface);
color: var(--oki-ink);
padding: var(--space-4);
max-inline-size: 24rem;
}
dialog::backdrop {
background: oklch(0% 0 0 / 0.5);
}
h2 {
font-size: var(--text-2);
margin-block-end: var(--space-3);
}
dl {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-block-end: var(--space-4);
}
dl div {
display: flex;
align-items: center;
gap: var(--space-3);
}
kbd {
display: inline-grid;
place-items: center;
min-inline-size: 2rem;
padding: 0.15em 0.5em;
border: 1px solid var(--oki-line);
border-bottom-width: 3px;
border-radius: var(--radius-1);
background: var(--oki-surface-2);
font-size: var(--text-0);
}
dd {
margin: 0;
}
</style>
+25 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import DifficultyBadge from './DifficultyBadge.svelte' import DifficultyBadge from './DifficultyBadge.svelte'
import { isAdopted } from '$lib/adopted.svelte'
import { t } from '$lib/i18n/index.svelte' import { t } from '$lib/i18n/index.svelte'
interface CardTool { interface CardTool {
@@ -22,6 +23,13 @@
{tool.monogram ?? tool.name.slice(0, 2)} {tool.monogram ?? tool.name.slice(0, 2)}
</span> </span>
<h3><a href="/outils/{tool.slug}">{tool.name}</a></h3> <h3><a href="/outils/{tool.slug}">{tool.name}</a></h3>
{#if isAdopted(tool.slug)}
<!-- information périphérique (doctrine §3.5) : discret, jamais bloquant -->
<span class="adopted" title={t('adopted.marked')}>
<span aria-hidden="true"></span>
<span class="visually-hidden">{t('adopted.marked')}</span>
</span>
{/if}
</header> </header>
<p class="excerpt"><span class="visually-hidden">{t('tool.why')} : </span>{tool.excerpt}</p> <p class="excerpt"><span class="visually-hidden">{t('tool.why')} : </span>{tool.excerpt}</p>
@@ -51,14 +59,17 @@
height: 100%; height: 100%;
} }
/* la couleur de marque en anneau, jamais en fond de texte :
contraste AA garanti quel que soit l'outil (doctrine §4.3) */
.monogram { .monogram {
display: grid; display: grid;
place-items: center; place-items: center;
inline-size: 2.5rem; inline-size: 2.5rem;
block-size: 2.5rem; block-size: 2.5rem;
border-radius: var(--radius-2); border-radius: var(--radius-2);
background: var(--tool-color, var(--oki-surface-2)); background: var(--oki-surface-2);
color: white; border: 3px solid var(--tool-color, var(--oki-line));
color: var(--oki-ink);
font-weight: 700; font-weight: 700;
font-size: var(--text-0); font-size: var(--text-0);
} }
@@ -83,6 +94,18 @@
border-radius: var(--radius-2); border-radius: var(--radius-2);
} }
.adopted {
display: grid;
place-items: center;
inline-size: 1.4rem;
block-size: 1.4rem;
border-radius: 50%;
background: var(--oki-vert);
color: oklch(98% 0 0);
font-size: var(--text-0);
margin-inline-start: auto;
}
.excerpt { .excerpt {
color: var(--oki-ink-2); color: var(--oki-ink-2);
font-size: var(--text-0); font-size: var(--text-0);
+26 -3
View File
@@ -2,7 +2,7 @@
* Chargeur de contenu — s'exécute uniquement au build (site prérendu, * Chargeur de contenu — s'exécute uniquement au build (site prérendu,
* ADR-001) : ni fs ni marked ne sont expédiés au client. * ADR-001) : ni fs ni marked ne sont expédiés au client.
*/ */
import { readFileSync, readdirSync } from 'node:fs' import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { marked } from 'marked' import { marked } from 'marked'
@@ -96,7 +96,30 @@ function loadTool(file: string): Tool {
} }
} }
let cache: { tools: Tool[]; categories: Category[] } | null = null export interface GcfTool {
sections: ToolSections
excerpt: string
}
/** Fiches jumelles créoles : content/outils/gcf/<slug>.md (CONTENT_GUIDE). */
function loadGcf(): Record<string, GcfTool> {
const dir = path.join(CONTENT, 'outils', 'gcf')
if (!existsSync(dir)) return {}
const out: Record<string, GcfTool> = {}
for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
const raw = readFileSync(path.join(dir, file), 'utf8')
const [meta, body] = parseFrontmatter(raw)
const sections = parseSections(body)
const why = stripHtml(sections.why ?? sections.what ?? '')
out[(meta.slug as string) ?? file.replace(/\.md$/, '')] = {
excerpt: why.length > 160 ? `${why.slice(0, 157).trimEnd()}` : why,
sections
}
}
return out
}
let cache: { tools: Tool[]; categories: Category[]; gcf: Record<string, GcfTool> } | null = null
export function loadContent() { export function loadContent() {
if (cache) return cache if (cache) return cache
@@ -112,6 +135,6 @@ export function loadContent() {
(order.get(a.category) ?? 99) - (order.get(b.category) ?? 99) || (order.get(a.category) ?? 99) - (order.get(b.category) ?? 99) ||
a.name.localeCompare(b.name, 'fr') a.name.localeCompare(b.name, 'fr')
) )
cache = { tools, categories } cache = { tools, categories, gcf: loadGcf() }
return cache return cache
} }
+53 -1
View File
@@ -15,6 +15,21 @@
"subtitle": "Reprends le contrôle de ton internet : des outils libres, expliqués simplement, du premier geste au serveur auto-hébergé.", "subtitle": "Reprends le contrôle de ton internet : des outils libres, expliqués simplement, du premier geste au serveur auto-hébergé.",
"cta": "Voir l'annuaire" "cta": "Voir l'annuaire"
}, },
"intro": {
"title": "Pourquoi ce site ?",
"p1": "L'Union européenne veut imposer le scan de toutes tes conversations privées : c'est le projet « Chat Control ». Et même quand la loi recule, l'infrastructure de surveillance, elle, continue d'avancer.",
"p2": "Ce site est un guide de terrain. Des outils libres, vérifiés, expliqués pas à pas — pour sortir des plateformes qui te surveillent et reprendre la main : toi, ta famille, ton lakou.",
"p3": "Pas besoin d'être technicien·ne. Commence par les fondations, avance à ton rythme, le reste vient après."
},
"sommaire": {
"title": "Le chimen",
"sub": "Trois étapes, du premier geste à l'autodéfense complète. Chaque catégorie est une porte d'entrée."
},
"arc": {
"fondations": "Les fondations",
"controle": "Reprendre le contrôle",
"avance": "Autodéfense avancée"
},
"directory": { "directory": {
"title": "Annuaire des outils", "title": "Annuaire des outils",
"intro": "Chaque outil est libre, vérifié, et classé par difficulté. Commence par le vert.", "intro": "Chaque outil est libre, vérifié, et classé par difficulté. Commence par le vert.",
@@ -40,7 +55,16 @@
"sourceCode": "Code source", "sourceCode": "Code source",
"license": "Licence", "license": "Licence",
"category": "Catégorie", "category": "Catégorie",
"backToDirectory": "Retour à l'annuaire" "backToDirectory": "Retour à l'annuaire",
"notTranslated": "Fich-lasa póko tradui an kréyòl — la voici en français.",
"draftTranslation": "Tradiksyon kréyòl an chantyé — un doute ? La version française fait foi."
},
"adopted": {
"mark": "Je m'en sers",
"marked": "Je m'en sers ✓",
"progressBefore": "Ou ja fè",
"progressAfter": "gestes — chaque outil adopté est une reprise de terrain.",
"srHint": "Enregistré uniquement sur cet appareil, jamais envoyé nulle part."
}, },
"difficulty": { "difficulty": {
"beginner": "Débutant", "beginner": "Débutant",
@@ -56,6 +80,34 @@
"switchLabel": "Langue", "switchLabel": "Langue",
"switchTo": "Passer en" "switchTo": "Passer en"
}, },
"shortcuts": {
"title": "Raccourcis clavier",
"search": "Rechercher",
"help": "Afficher cette aide",
"home": "Aller à l'accueil",
"directory": "Aller à l'annuaire",
"close": "Fermer"
},
"offline": {
"badge": "Hors-ligne",
"detail": "Tu lis la copie locale — les fiches déjà visitées restent disponibles."
},
"contrib": {
"title": "Pòté mannèv — Contribuer",
"intro": "Ce guide vit par celles et ceux qui s'en servent. Trois façons d'aider, de la plus simple à la plus complète.",
"suggest": "Proposer un outil",
"suggestBody": "Tu utilises un outil libre qui manque à l'annuaire ? Ouvre une demande avec le formulaire : nom, lien, pourquoi il compte. On s'occupe de la fiche.",
"suggestCta": "Ouvrir une demande",
"fix": "Corriger une fiche",
"fixBody": "Une étape d'installation qui a changé, un lien mort, une tournure plus claire ? Chaque fiche est un simple fichier texte dans le dossier content/outils/.",
"fixCta": "Parcourir les fiches",
"translate": "Traduire en kréyòl",
"translateBody": "Le créole est la langue du cœur de ce site. Les fiches attendent leur jumelle gcf — et les traductions existantes ont besoin de relecteurs et relectrices natifs.",
"translateCta": "Lire le guide de contenu",
"repo": "Tout se passe sur la forge",
"repoBody": "Le code, le contenu et les discussions vivent sur labola, la forge auto-hébergée d'OKI. Pas de compte Microsoft requis.",
"repoCta": "Voir le dépôt"
},
"footer": { "footer": {
"license": "Contenu et code libres (MIT). Zéro tracker, zéro CDN, zéro cookie.", "license": "Contenu et code libres (MIT). Zéro tracker, zéro CDN, zéro cookie.",
"contribute": "Proposer un outil ou une correction" "contribute": "Proposer un outil ou une correction"
+53 -1
View File
@@ -15,6 +15,21 @@
"subtitle": "Pran kontwòl entènèt a-w : zouti lib, èspliké senp, dépi prèmyé jès jis sèwvè a-w.", "subtitle": "Pran kontwòl entènèt a-w : zouti lib, èspliké senp, dépi prèmyé jès jis sèwvè a-w.",
"cta": "Gadé lis-la" "cta": "Gadé lis-la"
}, },
"intro": {
"title": "Poukisa sit-lasa ?",
"p1": "Linyon éwopéyèn vlé enposé skannaj a tout konvèsasyon privé a-w : sé pwojé « Chat Control ». É menm lè lwa-la ka fè bak, enfrastrikti a survéyans-la ka kontinyé vansé.",
"p2": "Sit-lasa sé on gid a téren. Zouti lib, vérifyé, èspliké pa a pa — pou sòti an platfòm ki ka survéyé-w é répran lanmen : vou, fanmi a-w, lakou a-w.",
"p3": "Pa bizwen sav enfòwmatik. Koumansé èvè fondasyon-la, vansé a vitès a-w, rès-la ka vin apré."
},
"sommaire": {
"title": "Chimen-la",
"sub": "Twa létap, dépi prèmyé jès jis otodéfans konplé. Chak katégori sé on pòt."
},
"arc": {
"fondations": "Fondasyon-la",
"controle": "Répran kontwòl",
"avance": "Otodéfans avansé"
},
"directory": { "directory": {
"title": "Lis a zouti", "title": "Lis a zouti",
"intro": "Chak zouti lib, vérifyé, é rangé pa difikilté. Koumansé èvè vèw-la.", "intro": "Chak zouti lib, vérifyé, é rangé pa difikilté. Koumansé èvè vèw-la.",
@@ -40,7 +55,16 @@
"sourceCode": "Kòd sous", "sourceCode": "Kòd sous",
"license": "Lisans", "license": "Lisans",
"category": "Katégori", "category": "Katégori",
"backToDirectory": "Viré an lis-la" "backToDirectory": "Viré an lis-la",
"notTranslated": "Fich-lasa póko tradui an kréyòl — mi'y an fransé.",
"draftTranslation": "Tradiksyon kréyòl an chantyé — on dout ? Vèwsyon fransé-la ka fè fwa."
},
"adopted": {
"mark": "An ka sèvi épi'y",
"marked": "An ka sèvi épi'y ✓",
"progressBefore": "Ou ja fè",
"progressAfter": "jès — chak zouti adopté sé on moso téren ou répran.",
"srHint": "Anrèjistré asi apawèy-lasa sèlman, janmé voyé okenn koté."
}, },
"difficulty": { "difficulty": {
"beginner": "Débitan", "beginner": "Débitan",
@@ -56,6 +80,34 @@
"switchLabel": "Lang", "switchLabel": "Lang",
"switchTo": "Pasé an" "switchTo": "Pasé an"
}, },
"shortcuts": {
"title": "Rakousi klavyé",
"search": "Chèché",
"help": "Montré èd-lasa",
"home": "Alé an lakou-la",
"directory": "Alé an lis-la",
"close": "Fèmé"
},
"offline": {
"badge": "Dékonèkté",
"detail": "Ou ka li kopi lokal-la — fich ou ja vizité ka rété la."
},
"contrib": {
"title": "Pòté mannèv",
"intro": "Gid-lasa ka viv gras a moun ki ka sèvi épi'y. Twa mannyè pou édé, dépi pi senp jis pi konplé.",
"suggest": "Pwopozé on zouti",
"suggestBody": "Ou ka sèvi épi on zouti lib ki manké an lis-la ? Louvè on dimann èvè fòwmilè-la : non, lyen, poukisa i konté. Nou ka okipé fich-la.",
"suggestCta": "Louvè on dimann",
"fix": "Korijé on fich",
"fixBody": "On létap ki chanjé, on lyen mò, on fraz pi klè ? Chak fich sé on senp fichyé tèks an dosyé content/outils/.",
"fixCta": "Gadé fich-la yo",
"translate": "Tradui an kréyòl",
"translateBody": "Kréyòl sé lang a kè a sit-lasa. Fich-la yo ka atann jimèl gcf a-yo — é tradiksyon ki ja la bizwen lektè natif.",
"translateCta": "Li gid a kontni-la",
"repo": "Tout ka pasé asi fòwj-la",
"repoBody": "Kòd, kontni é diskisyon ka viv asi labola, fòwj oto-ébèjé a OKI. Pa bizwen kont Microsoft.",
"repoCta": "Gadé dépo-la"
},
"footer": { "footer": {
"license": "Kontni é kòd lib (MIT). Pon trakè, pon CDN, pon cookie.", "license": "Kontni é kòd lib (MIT). Pon trakè, pon CDN, pon cookie.",
"contribute": "Pwopozé on zouti oben on koreksyon" "contribute": "Pwopozé on zouti oben on koreksyon"
+20
View File
@@ -0,0 +1,20 @@
/** Constantes du site — un seul endroit à changer si la forge bouge. */
export const REPO_URL = 'https://labola.o-k-i.net/cyber-mawonaj/lage-chat-control'
export const NEW_TOOL_ISSUE_URL = `${REPO_URL}/issues/new?template=.gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml`
/**
* Les trois arcs du chimen (sommaire) : progression Fondations →
* Reprendre le contrôle → Autodéfense avancée. Une catégorie appartient
* au premier arc dont `maxPart` couvre son `part`.
*/
export const ARCS = [
{ id: 'fondations', maxPart: 4, difficulty: 'beginner' },
{ id: 'controle', maxPart: 9, difficulty: 'intermediate' },
{ id: 'avance', maxPart: 99, difficulty: 'advanced' }
] as const
export type ArcId = (typeof ARCS)[number]['id']
export function arcOf(part: number): ArcId {
return (ARCS.find((a) => part <= a.maxPart) ?? ARCS[ARCS.length - 1]).id
}
+23 -8
View File
@@ -1,9 +1,27 @@
<script lang="ts"> <script lang="ts">
import '../app.css' import '../app.css'
import { onNavigate } from '$app/navigation'
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte' import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'
import OfflineBadge from '$lib/components/OfflineBadge.svelte'
import ShortcutsDialog from '$lib/components/ShortcutsDialog.svelte'
import { t } from '$lib/i18n/index.svelte' import { t } from '$lib/i18n/index.svelte'
import { REPO_URL } from '$lib/site'
let { children } = $props() let { children } = $props()
// View Transitions : la navigation glisse au lieu de sauter — amélioration
// progressive pure (navigateurs sans support : rien ne change), et
// désactivée si la personne préfère réduire les animations (doctrine §4.3).
onNavigate((navigation) => {
if (!document.startViewTransition) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve()
await navigation.complete
})
})
})
</script> </script>
<a class="skip-link" href="#main">{t('nav.skipToContent')}</a> <a class="skip-link" href="#main">{t('nav.skipToContent')}</a>
@@ -17,17 +35,16 @@
<nav aria-label={t('nav.home')}> <nav aria-label={t('nav.home')}>
<ul class="cluster"> <ul class="cluster">
<li><a href="/outils">{t('nav.directory')}</a></li> <li><a href="/outils">{t('nav.directory')}</a></li>
<li> <li><a href="/contribuer">{t('nav.contribute')}</a></li>
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
{t('nav.contribute')}
</a>
</li>
</ul> </ul>
</nav> </nav>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</header> </header>
<OfflineBadge />
<ShortcutsDialog />
<main id="main" class="center stack" style="--gap: var(--space-5)"> <main id="main" class="center stack" style="--gap: var(--space-5)">
{@render children()} {@render children()}
</main> </main>
@@ -35,9 +52,7 @@
<footer class="center"> <footer class="center">
<p>{t('footer.license')}</p> <p>{t('footer.license')}</p>
<p> <p>
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external"> <a href={REPO_URL} rel="external">{t('footer.contribute')}</a>
{t('footer.contribute')}
</a>
</p> </p>
</footer> </footer>
+19 -3
View File
@@ -1,11 +1,27 @@
import { loadContent } from '$lib/content/tools.server' import { loadContent } from '$lib/content/tools.server'
import { ARCS, arcOf } from '$lib/site'
export function load() { export function load() {
const { tools, categories } = loadContent() const { tools, categories } = loadContent()
const countBy = new Map<string, number>()
for (const tool of tools) {
countBy.set(tool.category, (countBy.get(tool.category) ?? 0) + 1)
}
// le sommaire : catégories groupées par arc (Fondations → Contrôle → Avancé)
const arcs = ARCS.map((arc) => ({
id: arc.id,
difficulty: arc.difficulty,
categories: categories
.filter((c) => arcOf(c.part) === arc.id)
.map((c) => ({ ...c, count: countBy.get(c.slug) ?? 0 }))
}))
return { return {
categories, arcs,
toolCount: tools.length, toolCount: tools.length,
// les premiers gestes : outils débutant, un par catégorie // les premiers gestes : outils débutant mis en avant
starters: tools.filter((t) => t.difficulty === 'beginner').slice(0, 3) starters: tools
.filter((t) => t.difficulty === 'beginner')
.slice(0, 3)
.map(({ sections: _sections, ...card }) => card)
} }
} }
+168 -13
View File
@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import ToolCard from '$lib/components/ToolCard.svelte' import ToolCard from '$lib/components/ToolCard.svelte'
import { adoptedCount } from '$lib/adopted.svelte'
import { t } from '$lib/i18n/index.svelte' import { t } from '$lib/i18n/index.svelte'
let { data } = $props() let { data } = $props()
// n'apparaît qu'après le premier geste : jamais de « 0/55 » culpabilisant
// (doctrine §3.3 — pas de métrique anxiogène)
let jes = $derived(adoptedCount())
</script> </script>
<svelte:head> <svelte:head>
@@ -16,6 +20,52 @@
<h1>{t('hero.title')}</h1> <h1>{t('hero.title')}</h1>
<p class="sub">{t('hero.subtitle')}</p> <p class="sub">{t('hero.subtitle')}</p>
<p><a class="cta" href="/outils">{t('hero.cta')} · {data.toolCount} {t('directory.results')}</a></p> <p><a class="cta" href="/outils">{t('hero.cta')} · {data.toolCount} {t('directory.results')}</a></p>
{#if jes > 0}
<p class="jes" role="status">
<span class="jes-mark" aria-hidden="true"></span>
{t('adopted.progressBefore')}
<strong>{jes}</strong>
{t('adopted.progressAfter')}
</p>
{/if}
</section>
<!-- L'intro publique : de quoi il s'agit, avant toute navigation.
Motif en F (doctrine §1.1) : l'essentiel au premier mot de chaque ¶. -->
<section class="intro stack prose">
<h2>{t('intro.title')}</h2>
<p>{t('intro.p1')}</p>
<p>{t('intro.p2')}</p>
<p>{t('intro.p3')}</p>
</section>
<!-- Le sommaire-chimen : trois arcs, une progression lisible sans lecture
(reconnaissance, doctrine §1.2) — le rail reprend vert/jaune/rouge,
doublé par texte + numérotation (jamais couleur seule, §1.1). -->
<section class="stack" aria-labelledby="sommaire-title">
<h2 id="sommaire-title">{t('sommaire.title')}</h2>
<p class="sub2">{t('sommaire.sub')}</p>
<ol class="chimen">
{#each data.arcs as arc, i (arc.id)}
<li class="arc arc-{arc.difficulty}">
<h3>
<span class="arc-num" aria-hidden="true">{i + 1}</span>
{t(`arc.${arc.id}`)}
<span class="arc-diff">{t(`difficulty.${arc.difficulty}`)}</span>
</h3>
<ul class="cluster">
{#each arc.categories as cat (cat.slug)}
<li>
<a href="/outils?cat={cat.slug}">
{cat.title}
<span class="count">{cat.count}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
</section> </section>
<section class="stack"> <section class="stack">
@@ -29,15 +79,6 @@
</div> </div>
</section> </section>
<section class="stack">
<h2>{t('directory.filterCategory')}</h2>
<ul class="cluster cats">
{#each data.categories as cat (cat.slug)}
<li><a href="/outils?cat={cat.slug}">{cat.title}</a></li>
{/each}
</ul>
</section>
<style> <style>
.hero { .hero {
padding-block: var(--space-5); padding-block: var(--space-5);
@@ -53,6 +94,10 @@
max-width: var(--measure); max-width: var(--measure);
} }
.sub2 {
color: var(--oki-ink-2);
}
/* une seule action primaire par écran (Von Restorff, doctrine §1.2) */ /* une seule action primaire par écran (Von Restorff, doctrine §1.2) */
.cta { .cta {
display: inline-flex; display: inline-flex;
@@ -70,14 +115,116 @@
filter: brightness(1.05); filter: brightness(1.05);
} }
.cats { .jes {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--oki-ink-2);
font-size: var(--text-0);
max-width: var(--measure);
}
.jes-mark {
display: grid;
place-items: center;
inline-size: 1.4rem;
block-size: 1.4rem;
border-radius: 50%;
background: var(--oki-vert);
color: oklch(98% 0 0);
flex-shrink: 0;
}
.intro {
border-inline-start: 4px solid var(--oki-rouge);
padding-inline-start: var(--space-4);
}
/* ── le chimen ─────────────────────────────────────────────────── */
.chimen {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
}
.arc {
--rail: var(--oki-line);
position: relative;
padding-inline-start: var(--space-5);
padding-block-end: var(--space-5);
}
/* le rail vertical, coloré par arc */
.arc::before {
content: '';
position: absolute;
inset-block: 0.4rem 0;
inset-inline-start: calc(1.1rem - 2px);
width: 4px;
border-radius: 2px;
background: var(--rail);
}
.arc:last-child::before {
inset-block-end: auto;
height: 2rem;
}
.arc-beginner {
--rail: var(--diff-beginner);
}
.arc-intermediate {
--rail: var(--diff-intermediate);
}
.arc-advanced {
--rail: var(--diff-advanced);
}
.arc h3 {
display: flex;
align-items: center;
gap: var(--space-2);
margin-block-end: var(--space-3);
}
.arc-num {
position: absolute;
inset-inline-start: 0;
display: grid;
place-items: center;
inline-size: 2.2rem;
block-size: 2.2rem;
border-radius: 50%;
background: var(--rail);
color: var(--oki-noir);
font-family: var(--font-display);
font-size: var(--text-1);
}
.arc-advanced .arc-num {
color: oklch(98% 0 0);
}
.arc-diff {
font-family: var(--font-text);
font-size: var(--text-0);
font-weight: 400;
color: var(--oki-ink-2);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: 0.1em 0.6em;
}
.arc ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
} }
.cats a { .arc a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2);
min-height: var(--tap-target); min-height: var(--tap-target);
padding-inline: var(--space-3); padding-inline: var(--space-3);
border: 1px solid var(--oki-line); border: 1px solid var(--oki-line);
@@ -86,7 +233,15 @@
color: var(--oki-ink); color: var(--oki-ink);
} }
.cats a:hover { .arc a:hover {
border-color: var(--oki-jaune); border-color: var(--rail);
}
.count {
font-size: var(--text-0);
color: var(--oki-ink-2);
background: var(--oki-surface-2);
border-radius: var(--radius-2);
padding: 0.05em 0.5em;
} }
</style> </style>
@@ -0,0 +1,91 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
import { NEW_TOOL_ISSUE_URL, REPO_URL } from '$lib/site'
// de la plus simple à la plus complète (divulgation progressive, §1.3)
const WAYS = [
{ key: 'suggest', href: NEW_TOOL_ISSUE_URL },
{ key: 'fix', href: `${REPO_URL}/src/branch/main/content/outils` },
{ key: 'translate', href: `${REPO_URL}/src/branch/main/docs/CONTENT_GUIDE.md` }
] as const
</script>
<svelte:head>
<title>{t('contrib.title')}{t('site.name')}</title>
<meta name="description" content={t('contrib.intro')} />
</svelte:head>
<header class="stack">
<h1>{t('contrib.title')}</h1>
<p class="prose">{t('contrib.intro')}</p>
</header>
<ol class="ways stack" style="--gap: var(--space-4)">
{#each WAYS as way, i (way.key)}
<li class="way stack">
<h2><span class="num" aria-hidden="true">{i + 1}</span>{t(`contrib.${way.key}`)}</h2>
<p class="prose">{t(`contrib.${way.key}Body`)}</p>
<p><a class="action" href={way.href} rel="external">{t(`contrib.${way.key}Cta`)}</a></p>
</li>
{/each}
</ol>
<section class="repo stack">
<h2>{t('contrib.repo')}</h2>
<p class="prose">{t('contrib.repoBody')}</p>
<p><a class="action" href={REPO_URL} rel="external">{t('contrib.repoCta')}</a></p>
</section>
<style>
.ways {
list-style: none;
padding: 0;
}
.way {
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: var(--space-4);
}
.way h2 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-3);
}
.num {
display: grid;
place-items: center;
inline-size: 2.2rem;
block-size: 2.2rem;
border-radius: 50%;
background: var(--oki-jaune);
color: var(--oki-noir);
font-size: var(--text-1);
flex-shrink: 0;
}
.action {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-4);
border: 2px solid var(--oki-jaune);
border-radius: var(--radius-2);
font-weight: 700;
text-decoration: none;
color: var(--oki-ink);
}
.action:hover {
background: var(--oki-jaune);
color: var(--oki-noir);
}
.repo {
border-inline-start: 4px solid var(--oki-vert);
padding-inline-start: var(--space-4);
}
</style>
+5 -2
View File
@@ -74,10 +74,11 @@
</div> </div>
<section class="stack" aria-live="polite"> <section class="stack" aria-live="polite">
<p class="count"> <!-- h2 : préserve l'ordre des titres (h1 → h2 → h3 des cartes) -->
<h2 class="count">
{filtered.length} {filtered.length}
{filtered.length === 1 ? t('directory.resultOne') : t('directory.results')} {filtered.length === 1 ? t('directory.resultOne') : t('directory.results')}
</p> </h2>
{#if filtered.length === 0} {#if filtered.length === 0}
<p class="empty">{t('directory.empty')}</p> <p class="empty">{t('directory.empty')}</p>
@@ -124,6 +125,8 @@
.count { .count {
color: var(--oki-ink-2); color: var(--oki-ink-2);
font-size: var(--text-0); font-size: var(--text-0);
font-family: var(--font-text);
font-weight: 400;
} }
.empty { .empty {
@@ -6,9 +6,10 @@ export function entries() {
} }
export function load({ params }: { params: { slug: string } }) { export function load({ params }: { params: { slug: string } }) {
const { tools, categories } = loadContent() const { tools, categories, gcf } = loadContent()
const tool = tools.find((t) => t.slug === params.slug) const tool = tools.find((t) => t.slug === params.slug)
if (!tool) error(404) if (!tool) error(404)
const category = categories.find((c) => c.slug === tool.category) const category = categories.find((c) => c.slug === tool.category)
return { tool, category } // la jumelle créole si elle existe (affichée selon la langue choisie)
return { tool, category, gcf: gcf[tool.slug] ?? null }
} }
+31 -7
View File
@@ -1,9 +1,14 @@
<script lang="ts"> <script lang="ts">
import AdoptButton from '$lib/components/AdoptButton.svelte'
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte' import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte' import { getLocale, t } from '$lib/i18n/index.svelte'
let { data } = $props() let { data } = $props()
let tool = $derived(data.tool) let tool = $derived(data.tool)
// fiche jumelle créole : affichée quand la langue est gcf et qu'elle existe ;
// sinon repli français signalé (jamais de page vide)
let useGcf = $derived(getLocale() === 'gcf' && data.gcf !== null)
let sections = $derived(useGcf && data.gcf ? data.gcf.sections : tool.sections)
const SECTION_ORDER = ['what', 'why', 'who', 'install', 'extra'] as const const SECTION_ORDER = ['what', 'why', 'who', 'install', 'extra'] as const
// « extra » n'a pas d'intitulé i18n dédié : rattaché à l'installation // « extra » n'a pas d'intitulé i18n dédié : rattaché à l'installation
@@ -61,32 +66,41 @@
<span>{tag}</span> <span>{tag}</span>
{/each} {/each}
</div> </div>
<p> <p class="cluster">
<a class="cta" href={tool.url} rel="external">{t('tool.officialSite')}</a> <a class="cta" href={tool.url} rel="external">{t('tool.officialSite')}</a>
<AdoptButton slug={tool.slug} />
</p> </p>
</header> </header>
{#if getLocale() === 'gcf'}
<p class="translation-note" role="note">
{data.gcf ? t('tool.draftTranslation') : t('tool.notTranslated')}
</p>
{/if}
{#each SECTION_ORDER as key (key)} {#each SECTION_ORDER as key (key)}
{#if tool.sections[key]} {#if sections[key]}
<section class="prose stack"> <section class="prose stack" lang={useGcf ? 'gcf' : 'fr'}>
<h2>{t(HEADINGS[key])}</h2> <h2>{t(HEADINGS[key])}</h2>
<!-- HTML produit au build par marked depuis notre propre contenu versionné --> <!-- HTML produit au build par marked depuis notre propre contenu versionné -->
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="stack">{@html tool.sections[key]}</div> <div class="stack">{@html sections[key]}</div>
</section> </section>
{/if} {/if}
{/each} {/each}
</article> </article>
<style> <style>
/* couleur de marque en anneau, texte en encre : contraste AA garanti */
.monogram { .monogram {
display: grid; display: grid;
place-items: center; place-items: center;
inline-size: 3.5rem; inline-size: 3.5rem;
block-size: 3.5rem; block-size: 3.5rem;
border-radius: var(--radius-2); border-radius: var(--radius-2);
background: var(--tool-color, var(--oki-surface-2)); background: var(--oki-surface-2);
color: white; border: 4px solid var(--tool-color, var(--oki-line));
color: var(--oki-ink);
font-weight: 700; font-weight: 700;
} }
@@ -117,6 +131,16 @@
text-decoration: none; text-decoration: none;
} }
.translation-note {
font-size: var(--text-0);
color: var(--oki-ink-2);
border: 1px dashed var(--oki-line);
border-inline-start: 4px solid var(--oki-jaune);
border-radius: var(--radius-1);
padding: var(--space-2) var(--space-3);
max-width: var(--measure);
}
section h2 { section h2 {
font-size: var(--text-3); font-size: var(--text-3);
border-bottom: 3px solid var(--oki-jaune); border-bottom: 3px solid var(--oki-jaune);
+71
View File
@@ -0,0 +1,71 @@
/// <reference types="@sveltejs/kit" />
/**
* Service worker offline-first (doctrine §2.1.7) : résilience réseau
* (3G irrégulière, forfaits comptés) + souveraineté (le guide reste lisible
* même si le site est bloqué ou hors-ligne après une première visite).
*
* Stratégies :
* - assets versionnés (/_app/immutable/…) : cache-first, immuables ;
* - pages HTML : network-first avec repli cache — chaque page visitée
* devient disponible hors-ligne (« cache des fiches consultées ») ;
* - coquille (/, /outils, polices, manifest) : précachée à l'installation.
*/
import { build, files, version } from '$service-worker'
const CACHE = `oki-${version}`
const SHELL = ['/', '/outils', '/contribuer', ...build, ...files]
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(SHELL))
.then(() => self.skipWaiting())
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
)
})
self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') return
const url = new URL(request.url)
if (url.origin !== self.location.origin) return
// assets au nom haché : jamais modifiés, cache-first
if (url.pathname.startsWith('/_app/immutable/')) {
event.respondWith(
caches.match(request).then((hit) => hit ?? fetch(request))
)
return
}
// pages et autres fichiers : network-first, la copie en cache est le filet
event.respondWith(
(async () => {
const cache = await caches.open(CACHE)
try {
const response = await fetch(request)
if (response.ok) cache.put(request, response.clone())
return response
} catch {
const hit = await cache.match(request, { ignoreSearch: request.mode === 'navigate' })
if (hit) return hit
// navigation vers une page jamais visitée : renvoyer l'accueil
// plutôt qu'une erreur réseau brute
if (request.mode === 'navigate') {
const home = await cache.match('/')
if (home) return home
}
throw new Error('offline')
}
})()
)
})