feat(oki): fondations de la migration vers la stack souveraine OKI

Architecture décidée et documentée dans ADR.md : SvelteKit natif en
fichiers plats plutôt que Strapi (55 outils, données statiques,
souveraineté et budget performance prioritaires — doctrine §1.2, §4.4).

- frontend/ : SvelteKit 2 + Svelte 5 (runes), adapter-static, 58 pages
  prérendues, CSS natif en @layer (reset→tokens→base→layouts→components),
  tokens oklch(), container queries, typo fluide clamp(), 2 polices WOFF2
  auto-hébergées (Atkinson Hyperlegible, Fraunces), zéro CDN tiers
- i18n FR/gcf typé (clé manquante = erreur de build), bascule Kréyòl
  persistée en localStorage, micro-textes créoles (« Ou pa manké ayen »)
- Composants : ToolCard, DifficultyBadge (forme+texte+couleur, jamais la
  couleur seule), CategoryNav (≤5 entrées, divulgation progressive),
  SearchBar (raccourci /), LanguageSwitcher, EndMarker
- content/ : 55 fiches migrées depuis src/ (script frontend/scripts/
  migrate-tools.mjs, idempotent), licences SPDX reprises de directory.json
- docs/ : DESIGN_SYSTEM.md et CONTENT_GUIDE.md

Budgets vérifiés : JS 41 Ko gzip, CSS 2,9 Ko gzip, svelte-check 0 erreur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sucupira
2026-07-10 12:51:17 -04:00
parent f4e09ca886
commit db5a997e50
99 changed files with 5356 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "oki-preview",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--dir", "frontend", "preview", "--port", "4173"],
"port": 4173
}
]
}
+4
View File
@@ -5,3 +5,7 @@ playwright-report/
test-results/
*.local
.DS_Store
# SvelteKit (frontend/)
.svelte-kit/
frontend/build/
+83
View File
@@ -0,0 +1,83 @@
# ADR-001 — Backend : SvelteKit natif (fichiers plats), pas de Strapi
**Statut** : accepté · **Date** : 2026-07-10 · **Décideur** : migration OKI d'exitchatcontrol.org
**Doctrine de référence** : `ui-ux-souveraine-oki.md` (citée « doctrine §x.y » ci-dessous)
## Contexte
exitchatcontrol.org est un annuaire de ~55 outils libres répartis en 23 sections
thématiques, actuellement généré statiquement par Astro à partir de fichiers
versionnés (`src/data/*.json`, `src/content/sections/*/*.mdx`). Le déploiement
cible est un conteneur nginx auto-hébergé (YunoHost/Docker), sans backend.
Deux options étaient sur la table (mission §2.1) :
- **Option A** : Strapi v5 headless, base relationnelle, admin web, API, i18n plugin.
- **Option B** : SvelteKit full-stack, contenu en fichiers Markdown/JSON versionnés,
parsés au build, site prérendu.
## Décision
**Option B — SvelteKit 2 + Svelte 5, contenu en fichiers plats prérendus.**
## Justification par la règle de décision
La règle imposée : minimiser la charge cognitive **extrinsèque** (doctrine §1.2,
Sweller) pour l'utilisateur final ET l'administrateur, dans le budget performance
(doctrine §4.4).
1. **Les données sont déjà statiques et versionnées.** 55 outils, mises à jour
par pull request espacées de plusieurs jours/semaines (historique git). Le seuil
« le volume de données justifie une base relationnelle » posé comme condition
de l'option A n'est pas atteint — 55 fiches tiennent dans un dossier de
fichiers Markdown lisibles à l'œil nu.
2. **Charge d'administration.** Strapi = un service Node persistant + une base +
des migrations de schéma + des mises à jour de sécurité + des sauvegardes.
Pour l'admin OKI, c'est de la charge extrinsèque pure : la tâche réelle
(« corriger une fiche ») devient « se connecter à un admin, naviguer un CMS,
publier, invalider un cache ». En fichiers plats : éditer un fichier, commit.
Le git devient l'historique éditorial, la revue par PR devient la modération.
3. **Budget performance (doctrine §4.4).** Site 100 % prérendu : premier rendu
= HTML servi par nginx, zéro requête API, JS initial minimal (hydratation
sélective). Strapi n'aiderait pas mais ajouterait un point de latence et de
panne si l'on cédait à la tentation du rendu à la demande.
4. **Souveraineté et surface d'attaque.** Un site statique n'a pas de CVE de CMS,
pas de compte admin à voler, pas de base à exfiltrer. C'est la posture
cohérente avec le sujet même du site (OPSEC, résistance à la censure) :
le site doit être **mirrorable** (un tarball du build suffit), ce qu'un
Strapi rend impossible.
5. **Loi de Tesler (doctrine §1.3)** : la complexité éditoriale n'est pas
supprimée, elle est déplacée vers le format de fichier. On l'absorbe par un
frontmatter strict + validation au build (le build échoue si une fiche est
invalide — équivalent des types du CMS, sans le CMS).
### Ce qui ferait rebasculer vers Strapi (critères de réversibilité)
- Une équipe éditoriale non-technique de plusieurs personnes publiant chaque semaine ;
- Des soumissions communautaires en volume nécessitant un workflow draft/publish ;
- Plus de ~500 fiches ou des relations réellement complexes.
Dans ce cas, le modèle de données de la mission §3 s'applique tel quel ; le
frontend SvelteKit resterait inchangé (il consommerait l'API au build).
Étape intermédiaire possible sans backend : Decap CMS (git-based) sur le dossier
`content/` — écarté pour l'instant, « préférer la solution la plus simple ».
## Décisions techniques associées
- **adapter-static + prerender total** : parité avec le déploiement nginx actuel
(Dockerfile conservé dans l'esprit). Un lien fonctionne sans JS (doctrine §4.4).
- **Markdown + frontmatter maison** : parseur frontmatter minimal (~40 lignes,
zéro dépendance) + `marked` (MIT) pour le corps, exécuté **au build uniquement**
— rien de tout cela n'est expédié au client.
- **i18n maison typé** (pattern repris du projet source) plutôt que
Paraglide/inlang : ~40 lignes, dictionnaires JSON `fr`/`gcf` vérifiés par le
compilateur TypeScript (une clé manquante = erreur de build). Paraglide reste
la piste si le nombre de locales croît.
- **Langues** : `fr` (défaut) + `gcf` (créole guadeloupéen, code ISO 639-3
enregistré BCP 47 valide). Micro-textes créoles d'abord (doctrine §4.2 :
impact affectif disproportionné pour un coût faible) ; traduction intégrale
des fiches = chantier éditorial séparé (voir `docs/CONTENT_GUIDE.md`).
- **CSS natif en `@layer`** : `reset → tokens → base → layouts → components →
utilities` (doctrine §5.2), tokens `oklch()`, container queries, `clamp()`.
Pas de Tailwind (doctrine §5.2 option 4 : écarté en connaissance de cause).
- **Licence du code** : MIT, comme le projet source (continuité).
+92
View File
@@ -0,0 +1,92 @@
[
{
"slug": "messagerie",
"title": "Messagerie chiffrée",
"order": 3,
"part": 1
},
{
"slug": "email",
"title": "E-mail chiffré & PGP",
"order": 4,
"part": 2
},
{
"slug": "navigateur",
"title": "Navigateur & recherche",
"order": 5,
"part": 3
},
{
"slug": "dns",
"title": "DNS chiffré & Cloudflare",
"order": 6,
"part": 3
},
{
"slug": "vpn",
"title": "VPN : la vérité",
"order": 7,
"part": 4
},
{
"slug": "stockage",
"title": "Stockage chiffré",
"order": 10,
"part": 6
},
{
"slug": "motsdepasse",
"title": "Gestionnaire de mots de passe",
"order": 11,
"part": 7
},
{
"slug": "deuxfa",
"title": "Double authentification & clés matérielles",
"order": 12,
"part": 7
},
{
"slug": "social",
"title": "Réseaux sociaux décentralisés",
"order": 13,
"part": 8
},
{
"slug": "argent",
"title": "Souveraineté financière",
"order": 14,
"part": 9
},
{
"slug": "ia",
"title": "\"L'IA conversationnelle\"",
"order": 15,
"part": 9
},
{
"slug": "boiteaoutils",
"title": "La boîte à outils du quotidien",
"order": 16,
"part": 9
},
{
"slug": "selfhost",
"title": "Auto-hébergement",
"order": 17,
"part": 10
},
{
"slug": "tor",
"title": "Tor",
"order": 18,
"part": 11
},
{
"slug": "os",
"title": "\"Systèmes d'exploitation libres\"",
"order": 19,
"part": 12
}
]
+42
View File
@@ -0,0 +1,42 @@
---
name: "Aegis · Ente Auth"
slug: "aegis"
category: "deuxfa"
difficulty: "beginner"
license: "GPL-3.0"
url: "https://getaegis.app"
color: "#2c5c9c"
monogram: "Ae"
iconSlug: "aegisauthenticator"
tags: ["Codes TOTP","🌍 · open-source"]
lang: "fr"
---
## what
Des applications qui génèrent les codes à six chiffres qui changent toutes les 30 secondes, en
remplacement de Google Authenticator.
## why
<b>Aegis</b> (Android) garde vos codes dans un coffre chiffré local, sans cloud.{' '}
<b>Ente Auth</b> ajoute une synchronisation chiffrée de bout en bout entre appareils, et a été
audité. Bien plus sûrs que le SMS, et gratuits.
## who
Tout le monde : c'est le niveau de 2FA à adopter par défaut, avant les clés matérielles.
## install
<ol>
<li>Installez Aegis (F-Droid) ou Ente Auth (toutes plateformes).</li>
<li>
Sur chaque compte, activez la 2FA « application d'authentification » et scannez le QR code
affiché.
</li>
<li>
Notez les <b>codes de secours</b> sur papier et faites une sauvegarde chiffrée de votre
coffre.
</li>
</ol>
+36
View File
@@ -0,0 +1,36 @@
---
name: "SimpleLogin · addy.io"
slug: "alias"
category: "boiteaoutils"
difficulty: "beginner"
url: "https://simplelogin.io"
color: "#c0392b"
monogram: "@"
iconSlug: "simplelogin"
tags: ["Alias e-mail","🌍 · open-source"]
lang: "fr"
---
## what
Des alias e-mail jetables : une adresse unique par site, qui redirige vers votre vraie boîte
sans jamais la révéler.
## why
Vous ne donnez plus votre vraie adresse partout. Si un site est piraté ou vous spamme, vous
coupez l'alias correspondant, et vous savez immédiatement qui a fait fuiter vos données.{' '}
<b>SimpleLogin</b> est intégré à Proton ; <b>addy.io</b> propose des alias illimités dès l'offre
gratuite.
## who
Tout le monde, et particulièrement les comptes pseudonymes qui veulent cloisonner leurs
inscriptions.
## install
<ol>
<li>Créez un compte, installez l'extension de navigateur.</li>
<li>À chaque inscription, générez un nouvel alias au lieu de saisir votre vraie adresse.</li>
</ol>
+42
View File
@@ -0,0 +1,42 @@
---
name: "Sauvegardes chiffrées"
slug: "backups"
category: "boiteaoutils"
difficulty: "intermediate"
url: "https://cryptomator.org"
color: "#5a6570"
monogram: "Bk"
iconSlug: "cryptomator"
tags: ["Cryptomator · restic","🌍 · open-source"]
lang: "fr"
---
## what
De quoi sauvegarder votre téléphone et votre ordinateur en chiffrant les données <b>avant</b>{' '}
qu'elles ne partent dans un cloud.
## why
Les sauvegardes iCloud/Google sont souvent accessibles au fournisseur, donc à la justice.{' '}
<b>Cryptomator</b> chiffre vos fichiers avant de les envoyer sur n'importe quel cloud ;{' '}
<b>restic</b> fait des sauvegardes chiffrées automatiques ; <b>Proton Drive</b> est chiffré de
bout en bout. Sur iPhone, activez la « Protection avancée des données » pour chiffrer vos
sauvegardes iCloud.
## who
Tout le monde devrait chiffrer ses sauvegardes. Règle d'or : deux copies chiffrées, sur deux
supports, en deux lieux.
## install
<ol>
<li>
iPhone : Réglages &gt; votre nom &gt; iCloud &gt; Protection avancée des données : activez-la.
</li>
<li>
PC : installez Cryptomator, créez un coffre, placez-y vos fichiers avant de synchroniser vers
le cloud.
</li>
</ol>
+34
View File
@@ -0,0 +1,34 @@
---
name: "Bitchat"
slug: "bitchat"
category: "messagerie"
difficulty: "advanced"
url: "https://bitchat.free"
color: "#e8871e"
monogram: "bit"
tags: ["Mesh Bluetooth","🌍 · open-source"]
lang: "fr"
---
## what
La messagerie mesh de Jack Dorsey : les téléphones proches se relaient les messages de proche en
proche, sans internet ni serveur ni compte.
## why
Chiffrement de bout en bout (AES-256-GCM) et un « mode panique » qui efface tout en trois
tapotements sur le logo.
## who
Manifestations et blackout, en complément de Briar. À utiliser avec prudence.
## install
<ol>
<li>Installez depuis l'App Store (iOS) ou l'APK/GitHub (Android).</li>
<li>Ouvrez l'application et choisissez un pseudo. Aucun compte à créer.</li>
<li>Les appareils proches se découvrent automatiquement en Bluetooth.</li>
<li>En cas de danger, triple-tapez le logo pour tout effacer (mode panique).</li>
</ol>
+38
View File
@@ -0,0 +1,38 @@
---
name: "Bitcoin (auto-conservation)"
slug: "bitcoin"
category: "argent"
difficulty: "intermediate"
url: "https://bitcoin.org"
color: "#f7931a"
monogram: "₿"
iconSlug: "bitcoin"
tags: ["Résistant à la censure"]
lang: "fr"
---
## what
Une monnaie numérique que vous détenez vous-même, sans passer par une banque.
## why
En <b>auto-conservation</b> (vos clés dans un portefeuille que vous seul contrôlez), personne ne
peut geler ni bloquer vos fonds. Sa confidentialité n'est pas parfaite (le registre est public),
mais il échappe à la censure bancaire, utile le jour où un paiement est refusé.
## who
Se prémunir contre le gel de compte ou la dé-bancarisation, et payer anonymement un service
comme un VPN.
## install
<ol>
<li>Procurez-vous des bitcoins (plateforme d'échange, ou entre particuliers).</li>
<li>
Transférez-les aussitôt vers un portefeuille que <b>vous</b> contrôlez : un portefeuille
matériel (Trezor, ColdCard) ou une application libre. Ne les laissez pas sur la plateforme.
</li>
<li>Sauvegardez votre phrase de récupération sur papier, jamais en photo ni en ligne.</li>
</ol>
+43
View File
@@ -0,0 +1,43 @@
---
name: "Bitwarden"
slug: "bitwarden"
category: "motsdepasse"
difficulty: "beginner"
license: "GPL-3.0"
url: "https://bitwarden.com"
color: "#175ddc"
monogram: "Bw"
iconSlug: "bitwarden"
tags: ["Le meilleur pour débuter","🌍 · open-source"]
lang: "fr"
---
## what
Un coffre-fort à mots de passe qui les mémorise, les génère et les remplit pour vous, sur tous
vos appareils.
## why
Gratuit, audité, multiplateforme, et chiffré de bout en bout. Vous n'avez plus qu'un seul mot de
passe fort à retenir. Il est même <b>auto-hébergeable</b> (via Vaultwarden) pour ne dépendre de
personne.
## who
Tout le monde. C'est le point de départ de toute la sécurité : un mot de passe unique et fort
par service.
## install
<ol>
<li>
Créez un compte sur bitwarden.com et choisissez un mot de passe maître <b>long</b> (une
phrase). Ne l'oubliez jamais : il n'est pas récupérable.
</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>
+44
View File
@@ -0,0 +1,44 @@
---
name: "Brave"
slug: "brave"
category: "navigateur"
difficulty: "beginner"
license: "MPL-2.0"
url: "https://brave.com"
color: "#fb542b"
monogram: "Bv"
iconSlug: "brave"
tags: ["Bloque tout par défaut","🌍 · open-source"]
lang: "fr"
---
## what
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.
## why
C'est l'option la plus simple pour quelqu'un qui vient de Chrome : même ergonomie, mais sans le
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
Les débutants qui veulent un changement indolore depuis Chrome. (Ignorez les fonctions
crypto/pubs récompensées si elles ne vous intéressent pas.)
## install
<ol>
<li>
Installez Brave depuis <b>brave.com</b>.
</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>
+42
View File
@@ -0,0 +1,42 @@
---
name: "Briar"
slug: "briar"
category: "messagerie"
difficulty: "advanced"
license: "GPL-3.0"
url: "https://briarproject.org"
color: "#c1272d"
monogram: "Br"
tags: ["Hors-ligne / P2P","Android · open-source"]
lang: "fr"
---
## what
Une messagerie pair-à-pair qui fonctionne <b>sans internet</b> : par Bluetooth, Wi-Fi Direct ou
Tor.
## why
Aucun serveur, donc rien à couper ni à saisir. Résiste aux coupures d'internet et à la censure
réseau.
## who
Manifestations, catastrophes, zones isolées, blackout. Android uniquement, et les deux personnes
doivent avoir l'application.
## install
<ol>
<li>
Installez depuis <b>briarproject.org</b> ou F-Droid (pas d'iOS).
</li>
<li>
Créez un compte <b>local</b> (pseudo + mot de passe), stocké seulement sur le téléphone.
</li>
<li>Ajoutez un contact proche en scannant son QR code, ou à distance via un lien.</li>
<li>
Quand le réseau est coupé, activez le Bluetooth : les messages passent d'appareil à appareil.
</li>
</ol>
+28
View File
@@ -0,0 +1,28 @@
---
name: "Duck.ai · Lumo (Proton)"
slug: "cloudai"
category: "ia"
difficulty: "beginner"
url: "https://duck.ai"
color: "#de5833"
monogram: "☁"
iconSlug: "duckduckgo"
tags: ["Cloud orienté vie privée"]
lang: "fr"
---
## what
Des accès à l'IA dans le cloud, mais conçus pour la vie privée, quand une IA locale n'est pas
possible.
## why
<b>Duck.ai</b> (DuckDuckGo) donne un accès anonymisé à plusieurs modèles. <b>Lumo</b> (Proton)
chiffre les échanges et ne s'en sert pas pour entraîner. Bien mieux que ChatGPT grand public,
mais cela reste un serveur distant.
## who
Ceux qui veulent la commodité du cloud sans nourrir les géants. Gardez-y les sujets non
sensibles.
+30
View File
@@ -0,0 +1,30 @@
---
name: "Cloudflare 1.1.1.1 (à nuancer)"
slug: "cloudflare"
category: "dns"
difficulty: "beginner"
url: "https://one.one.one.one"
color: "#f38020"
monogram: "CF"
iconSlug: "cloudflare"
tags: ["Rapide mais centralisé","🇺🇸"]
lang: "fr"
---
## what
Le DNS chiffré 1.1.1.1 et l'application WARP de Cloudflare : rapides, gratuits, et un vrai
progrès face au résolveur de votre FAI.
## why
C'est utile, avec un engagement de non-journalisation audité. Mais soyez lucide : Cloudflare se
trouve déjà devant une immense partie du web (comme intermédiaire technique, il peut voir le
trafic en clair vers ces sites). Y router aussi votre DNS revient à{' '}
<b>concentrer encore plus de vos traces</b> chez une seule entreprise américaine soumise au
droit américain.
## who
Bon pour un usage courant et le blocage des maliciels (1.1.1.1 for Families). À éviter ou à
diversifier pour un usage sensible : préférez alors Quad9 ou Mullvad DNS.
+41
View File
@@ -0,0 +1,41 @@
---
name: "Cryptomator"
slug: "cryptomator"
category: "stockage"
difficulty: "intermediate"
license: "GPL-3.0"
url: "https://cryptomator.org"
color: "#26a69a"
monogram: "Cy"
iconSlug: "cryptomator"
tags: ["Chiffre avant l'envoi","🇩🇪 · open-source"]
lang: "fr"
---
## what
Un coffre qui chiffre vos fichiers <b>avant</b> de les envoyer sur n'importe quel cloud, même
Google Drive ou Dropbox.
## why
L'astuce parfaite si vous devez garder un cloud existant : vous conservez le service pratique,
mais le fournisseur ne voit plus que du charabia chiffré. Vous gardez seul la clé.
## who
Ceux qui ne peuvent pas quitter un cloud grand public tout de suite, mais veulent protéger leurs
fichiers dès maintenant.
## install
<ol>
<li>Installez Cryptomator (PC et mobile) depuis cryptomator.org.</li>
<li>
Créez un « coffre » dans le dossier synchronisé de votre cloud, choisissez un mot de passe
fort.
</li>
<li>
Placez vos fichiers dans le coffre : ils sont chiffrés puis synchronisés automatiquement.
</li>
</ol>
+41
View File
@@ -0,0 +1,41 @@
---
name: "Effacer ses données (RGPD)"
slug: "databrokers"
category: "boiteaoutils"
difficulty: "intermediate"
url: "https://www.cnil.fr"
color: "#b8860b"
monogram: "RG"
tags: ["Courtiers en données"]
lang: "fr"
---
## what
Faire retirer vos informations personnelles des « courtiers en données » qui les collectent et
les revendent.
## why
En Europe, le RGPD (article 17) vous donne un <b>droit à l'effacement</b> : vous pouvez exiger,
gratuitement, qu'une entreprise supprime vos données, en principe sous un mois. Des services
comme <b>Incogni</b> automatisent ces demandes en masse, moyennant paiement.
## who
Ceux qui veulent réduire leur exposition. Attention : les données réapparaissent avec le temps,
et cela ne touche ni les registres publics ni les réseaux sociaux. Le faire soi-même est gratuit
mais fastidieux.
## install
<ol>
<li>
Pour une demande manuelle : écrivez au courtier en invoquant l'article 17 du RGPD et demandez
la suppression.
</li>
<li>
Pour automatiser : souscrivez à un service de retrait et laissez-le envoyer les demandes en
votre nom.
</li>
</ol>
+39
View File
@@ -0,0 +1,39 @@
---
name: "Matrix / Element"
slug: "element"
category: "social"
difficulty: "intermediate"
url: "https://element.io"
color: "#0dbd8b"
monogram: "El"
iconSlug: "element"
tags: ["Remplace Discord / Slack","🌍 · fédéré · E2EE"]
lang: "fr"
---
## what
Une messagerie de communautés, dans l'esprit de Discord ou Slack, mais fédérée et chiffrable de
bout en bout. Element est l'application, Matrix le réseau.
## why
Vous pouvez héberger votre propre serveur (Synapse) et contrôler vos données. Parfait pour un
collectif, une rédaction, une association qui veut son espace indépendant.
## who
Communautés et groupes. Voir la nuance sur les métadonnées ci-dessous.
## install
<ol>
<li>
Installez Element (element.io) et créez un compte sur un serveur public, ou sur le vôtre
(section 10).
</li>
<li>
Rejoignez ou créez des salons, et activez le chiffrement de bout en bout pour les
conversations privées.
</li>
</ol>
+25
View File
@@ -0,0 +1,25 @@
---
name: "Pixelfed · PeerTube · Lemmy"
slug: "fediverse"
category: "social"
difficulty: "intermediate"
url: "https://pixelfed.org"
color: "#ea580c"
monogram: "Pi"
iconSlug: "pixelfed"
tags: ["Insta · YouTube · Reddit","🌍 · open-source"]
lang: "fr"
---
## what
Les cousins fédérés d'Instagram (Pixelfed), de YouTube (PeerTube) et de Reddit (Lemmy).
## why
Même logique que Mastodon : pas de propriétaire unique, pas d'algorithme publicitaire, pas de
traçage. Vous publiez photos, vidéos ou discussions sans nourrir une machine à profiler.
## who
Ceux qui veulent quitter Instagram, YouTube ou Reddit sans renoncer au format.
+42
View File
@@ -0,0 +1,42 @@
---
name: "Firefox (durci)"
slug: "firefox"
category: "navigateur"
difficulty: "beginner"
license: "MPL-2.0"
url: "https://www.mozilla.org/firefox"
color: "#e66000"
monogram: "Fx"
iconSlug: "firefoxbrowser"
tags: ["Remplace Chrome","🌍 · open-source"]
lang: "fr"
---
## what
Le navigateur libre de référence pour un usage quotidien respectueux de la vie privée.
## why
Contrairement à Chrome (un mouchard publicitaire de Google), Firefox est développé par une
fondation et n'a pas d'intérêt à vous profiler. Avec quelques réglages (le guide{' '}
<em>arkenfox</em>) et l'extension uBlock Origin, il devient très solide.
## who
Tout le monde, comme navigateur principal au quotidien.
## install
<ol>
<li>
Installez Firefox depuis <b>mozilla.org/firefox</b>.
</li>
<li>
Ajoutez l'extension <b>uBlock Origin</b> (blocage des pubs et traqueurs).
</li>
<li>
Dans les réglages de vie privée, choisissez « Stricte », désactivez la télémétrie, et videz
les cookies à la fermeture. Pour aller plus loin, appliquez le guide arkenfox.
</li>
</ol>
+50
View File
@@ -0,0 +1,50 @@
---
name: "GrapheneOS"
slug: "grapheneos"
category: "os"
difficulty: "advanced"
license: "MIT"
url: "https://grapheneos.org"
color: "#1f8a70"
monogram: "Gr"
iconSlug: "grapheneos"
tags: ["Mobile dégooglisé","Android · Pixel · open-source"]
lang: "fr"
---
## what
Un Android entièrement dégooglisé, installé sur un téléphone Pixel : la souveraineté sur mobile.
## why
C'est la défense structurelle par excellence contre un OS ou un magasin d'applications qui
voudrait vous scanner. Services Google en bac à sable optionnel,{' '}
<b>permissions réseau par application</b>, profils cloisonnés, redémarrage automatique qui purge
les clés de la mémoire, code PIN de contrainte qui efface le téléphone. Réputé pour sa sécurité
au point d'être utilisé par des cibles de logiciels espions.
## who
Profils exposés, mais aussi tout citoyen prêt à acheter un Pixel pour reprendre le contrôle de
son téléphone.
## install
<ol>
<li>
Procurez-vous un téléphone <b>Google Pixel</b> compatible (c'est le seul matériel supporté,
pour des raisons de sécurité).
</li>
<li>
Sur un ordinateur, allez sur <b>grapheneos.org/install</b> et suivez l'installeur web
(quelques clics, aucun terminal requis).
</li>
<li>
Pour vos applications, installez le magasin libre F-Droid ; ajoutez Google Play en bac à sable
seulement si nécessaire.
</li>
<li>
Réglez un long mot de passe (pas un code à 4 chiffres) et découvrez le mode « Lockdown ».
</li>
</ol>
+40
View File
@@ -0,0 +1,40 @@
---
name: "IVPN"
slug: "ivpn"
category: "vpn"
difficulty: "intermediate"
url: "https://www.ivpn.net"
color: "#3b5bdb"
monogram: "iv"
tags: ["Sans e-mail · no-log","🇬🇮 · open-source"]
lang: "fr"
---
## what
Un petit fournisseur de VPN particulièrement rigoureux et transparent, sur le même modèle que
Mullvad.
## why
Politique no-log auditée, pas d'e-mail obligatoire, paiement en crypto ou en espèces. Il propose
même des options anti-traçage (blocage des trackers au niveau réseau) et une inscription sans
aucun identifiant.
## who
Une alternative solide à Mullvad, pour les profils pseudonymes et à haut risque qui veulent
diversifier.
## install
<ol>
<li>
Sur <b>ivpn.net</b>, générez un compte (un identifiant est créé pour vous, sans e-mail).
</li>
<li>Payez en cryptomonnaie ou en espèces, comme pour Mullvad.</li>
<li>
Installez l'application, connectez-vous hors UE, activez le kill switch et le protocole
WireGuard.
</li>
</ol>
+38
View File
@@ -0,0 +1,38 @@
---
name: "KeePassXC"
slug: "keepassxc"
category: "motsdepasse"
difficulty: "advanced"
license: "GPL-2.0/3.0"
url: "https://keepassxc.org"
color: "#2c6b9e"
monogram: "Kp"
iconSlug: "keepassxc"
tags: ["100% local","🌍 · open-source"]
lang: "fr"
---
## what
Un coffre-fort <b>local</b> : un fichier chiffré que vous seul détenez, sans aucun cloud ni
compte.
## why
Rien ne quitte votre appareil, sauf si vous décidez de synchroniser le fichier vous-même (par
exemple via Cryptomator). C'est le choix des puristes qui ne veulent aucun intermédiaire.
## who
Les profils avancés qui veulent le contrôle total et zéro dépendance en ligne.
## install
<ol>
<li>
Installez KeePassXC (PC) et une application compatible sur mobile (KeePassDX sur Android,
Strongbox sur iOS).
</li>
<li>Créez un fichier de base de données protégé par un mot de passe maître.</li>
<li>Pour l'avoir sur tous vos appareils, synchronisez ce fichier via un cloud chiffré.</li>
</ol>
+53
View File
@@ -0,0 +1,53 @@
---
name: "Linux (sur ordinateur)"
slug: "linux"
category: "os"
difficulty: "intermediate"
url: "https://distrochooser.de/fr"
color: "#2b2b2b"
monogram: "🐧"
iconSlug: "linux"
tags: ["Remplace Windows / macOS","🌍 · libre &amp; gratuit"]
lang: "fr"
---
## what
Un système d'exploitation libre et gratuit pour votre ordinateur, en remplacement de Windows ou
de macOS. Il en existe de nombreuses variantes, appelées « distributions ».
## why
C'est le point aveugle de beaucoup de gens : ils chiffrent leurs messages mais gardent un
Windows qui transmet en continu de la télémétrie à Microsoft (et dont la fonction « Recall » a
voulu photographier l'écran en permanence), ou un macOS qui signale les applications que vous
lancez. Linux, lui, ne vous espionne pas, il est gratuit, il ressuscite les vieux ordinateurs et
il vous rend enfin maître de votre machine. C'est plus facile que sa réputation : les
distributions modernes ressemblent à Windows ou macOS.
## who
Tout le monde peut franchir le pas. Pour débuter, <b>Linux Mint</b> (le plus proche de Windows)
ou <b>Fedora</b> ; <b>Debian</b> pour la stabilité. Pas besoin de tout casser : on peut d'abord
tester, puis installer à côté de son système actuel.
## install
<ol>
<li>
Pas sûr de la distribution ? Répondez au questionnaire <b>distrochooser.de/fr</b>, qui vous
oriente selon vos besoins.
</li>
<li>
Testez-la <b>sans rien installer</b> directement dans votre navigateur sur{' '}
<b>distrosea.com</b>.
</li>
<li>
Une fois convaincu, téléchargez l'image de la distribution et créez une clé USB de démarrage
(avec Ventoy ou Balena Etcher).
</li>
<li>
Démarrez sur la clé pour essayer le système « en live », puis lancez l'installation. Activez
le chiffrement du disque proposé.
</li>
</ol>
+46
View File
@@ -0,0 +1,46 @@
---
name: "Ollama · LM Studio · Jan · GPT4All"
slug: "localai"
category: "ia"
difficulty: "intermediate"
url: "https://lmstudio.ai"
color: "#111111"
monogram: "Ai"
iconSlug: "ollama"
tags: ["Remplace ChatGPT","🌍 · 100% local"]
lang: "fr"
---
## what
Des logiciels pour faire tourner un modèle d'intelligence artificielle directement sur votre
ordinateur.
## why
Vos questions ne quittent jamais l'appareil : aucun serveur, aucun compte, aucune télémétrie, et
ça marche hors ligne. La qualité dépend de votre matériel, mais rien ne fuit. <b>Ollama</b> et{' '}
<b>LM Studio</b> sont les plus simples ; <b>Jan</b> et <b>GPT4All</b> visent la vie privée
maximale.
## who
Tous ceux qui utilisent l'IA pour des sujets un tant soit peu personnels et ne veulent pas les
confier à un serveur américain.
## install
<ol>
<li>
Pour débuter en douceur, installez <b>LM Studio</b> ou <b>Jan</b> (interface graphique, comme
une application classique).
</li>
<li>
Téléchargez un modèle proposé dans l'application (par exemple Llama ou Mistral), adapté à la
puissance de votre machine.
</li>
<li>
Discutez : tout se passe en local. Ollama est la version en ligne de commande pour les plus
techniques.
</li>
</ol>
+33
View File
@@ -0,0 +1,33 @@
---
name: "mailbox.org · Posteo"
slug: "mailbox"
category: "email"
difficulty: "beginner"
url: "https://mailbox.org"
color: "#2470d4"
monogram: "mx"
tags: ["Mail classique éthique","🇩🇪 · UE · PGP"]
lang: "fr"
---
## what
Deux fournisseurs de mail européens, éthiques et peu coûteux, avec support PGP côté serveur.
## why
Respectueux de la vie privée et de l'écologie, sur des standards ouverts (IMAP, PGP). Ils
fonctionnent avec n'importe quel logiciel de messagerie.
## who
Ceux qui veulent un mail « classique » (relevé dans Thunderbird) mais éthique, plutôt qu'une
messagerie chiffrée fermée.
## install
<ol>
<li>Créez un compte (payant, de l'ordre de 1 à 3 € par mois).</li>
<li>Activez le chiffrement PGP côté serveur (fonction « Guard » chez mailbox.org).</li>
<li>Configurez le compte dans Thunderbird ou l'application mobile de votre choix.</li>
</ol>
+37
View File
@@ -0,0 +1,37 @@
---
name: "Organic Maps · OsmAnd"
slug: "maps"
category: "boiteaoutils"
difficulty: "beginner"
url: "https://organicmaps.app"
color: "#0a7d33"
monogram: "Om"
iconSlug: "openstreetmap"
tags: ["Remplace Google Maps","🌍 · open-source"]
lang: "fr"
---
## what
Des applications de cartes et de navigation, fondées sur OpenStreetMap, qui fonctionnent hors
ligne.
## why
Google Maps enregistre chacun de vos trajets et bâtit un historique de lieux d'une précision
redoutable. <b>Organic Maps</b> ne trace rien et n'affiche aucune publicité ; <b>OsmAnd</b> vise
les utilisateurs avancés (randonnée, courbes de niveau, navigation détaillée).
## who
Tout le monde. Le trafic en temps réel et les transports sont moins complets que chez Google,
mais la navigation quotidienne est excellente.
## install
<ol>
<li>Installez depuis F-Droid, l'App Store ou Google Play.</li>
<li>
Téléchargez à l'avance les cartes des régions qui vous intéressent pour l'usage hors ligne.
</li>
</ol>
+41
View File
@@ -0,0 +1,41 @@
---
name: "Mastodon"
slug: "mastodon"
category: "social"
difficulty: "beginner"
license: "AGPL-3.0"
url: "https://joinmastodon.org"
color: "#6364ff"
monogram: "Ma"
iconSlug: "mastodon"
tags: ["Remplace X / Twitter","🌍 · open-source"]
lang: "fr"
---
## what
L'alternative à X/Twitter : un réseau social fait de milliers de serveurs indépendants qui
communiquent entre eux (le « fédiverse »).
## why
Pas de patron unique, pas d'algorithme qui décide à votre place, pas de bannissement arbitraire
par une seule entreprise. Le fil est chronologique, sans publicité ni manipulation.
## who
Tout le monde. Idéal pour les comptes d'information qui veulent une présence qu'aucune
entreprise ne peut couper.
## install
<ol>
<li>
Sur joinmastodon.org, choisissez un serveur (ou « instance ») qui vous correspond, puis créez
un compte.
</li>
<li>
Installez une application (l'officielle, ou Ivory, Tusky…) et suivez des comptes : vous voyez
tout le fédiverse, quel que soit leur serveur.
</li>
</ol>
+39
View File
@@ -0,0 +1,39 @@
---
name: "Molly"
slug: "molly"
category: "messagerie"
difficulty: "advanced"
license: "GPL-3.0"
url: "https://molly.im"
color: "#6d4bb3"
monogram: "M"
tags: ["Signal durci","Android · open-source"]
lang: "fr"
---
## what
Une version durcie de Signal pour Android, compatible avec le même réseau.
## why
Base de données chiffrée au repos, verrouillage automatique, et une variante « FOSS »
entièrement sans services Google.
## who
Utilisateurs avancés, notamment sur GrapheneOS, avec un modèle de menace élevé. Android
uniquement.
## install
<ol>
<li>
Ajoutez le dépôt Molly dans F-Droid, ou récupérez l'APK sur <b>molly.im</b>.
</li>
<li>
Choisissez <b>Molly-FOSS</b> si vous voulez zéro dépendance Google.
</li>
<li>Enregistrez-vous avec votre numéro (même réseau que Signal).</li>
<li>Définissez un mot de passe de base de données et le verrouillage automatique.</li>
</ol>
+39
View File
@@ -0,0 +1,39 @@
---
name: "Monero (XMR)"
slug: "monero"
category: "argent"
difficulty: "advanced"
license: "BSD-3-Clause"
url: "https://www.getmonero.org"
color: "#ff6600"
monogram: "ɱ"
iconSlug: "monero"
tags: ["Confidentiel par défaut"]
lang: "fr"
---
## what
La cryptomonnaie de la vie privée : montants, expéditeur et destinataire sont masqués par
défaut.
## why
C'est l'exact opposé de Bitcoin sur ce point : les transactions sont confidentielles par
conception. C'est le meilleur moyen de payer un service sans laisser de trace reliable à vous.
## who
Ceux qui veulent le maximum de confidentialité financière. Note : Monero est déjà retiré de
plusieurs plateformes en Europe, il faut souvent passer par un échange décentralisé.
## install
<ol>
<li>Installez un portefeuille (l'officiel sur getmonero.org, ou Cake Wallet sur mobile).</li>
<li>
Obtenez des XMR via un service d'échange décentralisé (par exemple un « swap » depuis une
autre crypto).
</li>
<li>Sauvegardez votre phrase de récupération sur papier.</li>
</ol>
+62
View File
@@ -0,0 +1,62 @@
---
name: "Mullvad VPN"
slug: "mullvad"
category: "vpn"
difficulty: "beginner"
url: "https://mullvad.net"
color: "#2b4b8f"
monogram: "Mv"
iconSlug: "mullvad"
tags: ["Sans compte · cash/Monero","🇸🇪 · open-source"]
lang: "fr"
---
## what
Un VPN qui masque votre adresse IP et chiffre votre trafic jusqu'à ses serveurs, sans jamais
vous demander la moindre information personnelle.
## why
C'est l'étalon-or de la vie privée. Pas d'e-mail, pas de nom : à l'inscription, on vous attribue
un simple numéro de compte. Politique « no-log » auditée, prix unique et honnête (5 € par mois,
sans palier ni fausse promesse), et paiement possible en espèces ou en Monero. C'est précisément
ce qui compte le jour où un État tente de couper l'accès aux VPN : un compte impossible à relier
à votre identité, et un paiement que les banques ne peuvent pas bloquer.
## who
Tout le monde, du citoyen qui veut contourner une vérification d'identité au profil à haut
risque. C'est le VPN à recommander en priorité.
## install
<ol>
<li>
Allez sur <b>mullvad.net</b>. Si le site est bloqué dans votre pays, ouvrez-le via le
navigateur Tor ou son adresse <b>.onion</b>. Cliquez sur « Générer un numéro de compte » :
aucun e-mail ni nom n'est demandé. Notez ce numéro dans votre gestionnaire de mots de passe et
ne le partagez jamais, c'est votre unique clé d'accès.
</li>
<li>
Choisissez la durée et le mode de paiement. Le prix est fixe. Trois voies : carte bancaire (le
plus simple si elle n'est pas bloquée), espèces envoyées par courrier, ou cryptomonnaie.
</li>
<li>
<b>Paiement anonyme en crypto :</b> cliquez sur « Créer une adresse de paiement à usage unique
». Mullvad affiche une adresse (Bitcoin ou Monero) et un montant exact. Envoyez ce montant
depuis votre portefeuille. Pour l'anonymat maximal, préférez <b>Monero</b> (confidentiel par
défaut) ; Bitcoin est plus simple mais traçable. Voir la section 09 pour acheter de la crypto.
Comptez environ 30 minutes de confirmation.
</li>
<li>
Téléchargez l'application sur <b>mullvad.net/download</b> (via Tor si le site est filtré) et
installez-la. Sur mobile, passez par l'App Store, Google Play, ou l'APK officiel si
l'application a été retirée de votre magasin.
</li>
<li>
Lancez l'application, entrez votre numéro de compte, puis connectez-vous à un serveur situé{' '}
<b>hors de votre pays</b>, idéalement hors Union européenne (Suisse, États-Unis…). Activez le
« kill switch » (qui coupe Internet si le VPN tombe) et le DNS de Mullvad.
</li>
</ol>
+41
View File
@@ -0,0 +1,41 @@
---
name: "Mullvad Browser"
slug: "mullvadbrowser"
category: "navigateur"
difficulty: "intermediate"
url: "https://mullvad.net/browser"
color: "#2b4b8f"
monogram: "Mb"
iconSlug: "mullvad"
tags: ["Anti-empreinte","🇸🇪 · open-source"]
lang: "fr"
---
## what
Le navigateur du Tor Browser, mais sans le réseau Tor : à utiliser avec votre VPN pour un
ordinateur difficile à pister.
## why
Développé avec le Tor Project, il est conçu pour que tous ses utilisateurs présentent la{' '}
<b>même empreinte numérique</b>. Résultat : au lieu d'être unique et donc traçable, vous vous
fondez dans la masse. C'est le meilleur choix de vie privée sur ordinateur.
## who
Ceux qui veulent aller au-delà du simple blocage de pub, notamment les comptes pseudonymes et
les profils exposés.
## install
<ol>
<li>
Téléchargez-le sur <b>mullvad.net/browser</b> (Windows, macOS, Linux).
</li>
<li>
Utilisez-le tel quel, sans le personnaliser : chaque extension ou réglage ajouté vous rend à
nouveau identifiable.
</li>
<li>Associez-le à votre VPN pour masquer aussi votre adresse IP.</li>
</ol>
+40
View File
@@ -0,0 +1,40 @@
---
name: "Mullvad DNS · NextDNS"
slug: "mullvaddns"
category: "dns"
difficulty: "intermediate"
url: "https://mullvad.net/help/dns-over-https-and-dns-over-tls"
color: "#2b4b8f"
monogram: "DNS"
iconSlug: "mullvad"
tags: ["Filtrage réseau"]
lang: "fr"
---
## what
Deux résolveurs chiffrés qui bloquent en plus pubs et traqueurs pour tout l'appareil.
## why
<b>Mullvad DNS</b> (Suède, no-log) est gratuit et propose des listes de blocage. <b>NextDNS</b>{' '}
est très personnalisable, avec un tableau de bord et des profils par appareil, mais il est basé
aux États-Unis et journalise par défaut (à désactiver dans les réglages).
## who
Ceux qui veulent un blocage des pubs à l'échelle du système, sans installer d'application par
navigateur.
## install
<ol>
<li>
Mullvad DNS : utilisez l'adresse indiquée sur mullvad.net/help/dns comme DNS privé (même
méthode que Quad9).
</li>
<li>
NextDNS : créez un profil sur nextdns.io, <b>désactivez la journalisation</b>, puis appliquez
la configuration à vos appareils.
</li>
</ol>
+25
View File
@@ -0,0 +1,25 @@
---
name: "Nextcloud"
slug: "nc-storage"
category: "stockage"
difficulty: "advanced"
url: "https://nextcloud.com"
color: "#0082c9"
monogram: "Nc"
iconSlug: "nextcloud"
tags: ["Votre propre cloud","🌍 · open-source"]
lang: "fr"
---
## what
Votre propre nuage (fichiers, agenda, contacts, notes) hébergé sur votre serveur.
## why
Le degré ultime de contrôle : vos données ne quittent jamais votre matériel. Détaillé dans la
section 10 (Auto-hébergement).
## who
Les auto-hébergeurs. Voir la fiche complète en section 10.
+29
View File
@@ -0,0 +1,29 @@
---
name: "Nextcloud"
slug: "nextcloud"
category: "selfhost"
difficulty: "advanced"
license: "AGPL-3.0"
url: "https://nextcloud.com"
color: "#0082c9"
monogram: "Nc"
iconSlug: "nextcloud"
tags: ["Remplace toute la suite Google","🇩🇪 · open-source"]
lang: "fr"
---
## what
Votre propre nuage : fichiers, agenda, contacts, photos, notes et documents collaboratifs, sur
votre serveur.
## why
C'est le remplacement complet de Google Drive, Agenda et Contacts, mais chez vous. Vous décidez
de la juridiction et personne d'autre n'a la main sur vos fichiers. À savoir : c'est la
confidentialité « votre serveur », pas un chiffrement de bout en bout par défaut ; vous devez le
maintenir à jour.
## who
Les auto-hébergeurs. Le plus simple est de l'installer via YunoHost ou Umbrel.
+39
View File
@@ -0,0 +1,39 @@
---
name: "Nostr"
slug: "nostr"
category: "social"
difficulty: "intermediate"
url: "https://nostr.com"
color: "#8e30eb"
monogram: "No"
tags: ["Incensurable","🌍 · protocole ouvert"]
lang: "fr"
---
## what
Pas une application mais un protocole : un réseau social où votre identité est une simple paire
de clés, comme pour Bitcoin.
## why
Zéro e-mail, zéro mot de passe, aucun compte qu'on puisse supprimer. Vos messages voyagent sur
des relais décentralisés et vous possédez vraiment votre audience : personne ne peut vous bannir
durablement. Vous pouvez même recevoir des pourboires en Bitcoin (les « zaps ») et changer
d'application quand vous voulez sans perdre vos abonnés.
## who
Comptes d'information et créateurs qui craignent la censure de plateforme et veulent une
présence vraiment à eux.
## install
<ol>
<li>Installez un client : Damus (iPhone), Amethyst ou Primal (Android), ou une version web.</li>
<li>
L'application génère votre paire de clés. <b>Sauvegardez votre clé privée (nsec)</b> comme une
phrase de récupération Bitcoin : sur papier, jamais partagée.
</li>
<li>Créez votre profil, suivez des gens, activez les zaps pour recevoir des sats.</li>
</ol>
+37
View File
@@ -0,0 +1,37 @@
---
name: "Standard Notes · Joplin · CryptPad"
slug: "notes"
category: "boiteaoutils"
difficulty: "beginner"
url: "https://standardnotes.com"
color: "#2e7d6b"
monogram: "No"
tags: ["Remplace Docs / Notion","🌍 · open-source"]
lang: "fr"
---
## what
Des notes et documents chiffrés de bout en bout, pour remplacer Google Docs, Notion ou Evernote.
## why
<b>Standard Notes</b> (par Proton) chiffre vos notes par défaut. <b>Joplin</b> gère des carnets
en Markdown avec chiffrement optionnel et la synchronisation de votre choix. <b>CryptPad</b>{' '}
(français) offre l'équivalent de Google Docs en temps réel, entièrement chiffré.
## who
Tout le monde pour les notes ; CryptPad pour la collaboration à plusieurs.
## install
<ol>
<li>
Standard Notes / Joplin : créez un compte, installez l'application, et activez le chiffrement
(par défaut sur Standard Notes, à activer dans Joplin).
</li>
<li>
CryptPad : utilisez cryptpad.fr ou une autre instance, aucun compte requis pour commencer.
</li>
</ol>
+41
View File
@@ -0,0 +1,41 @@
---
name: "Ente · Immich"
slug: "photos"
category: "boiteaoutils"
difficulty: "intermediate"
url: "https://ente.io"
color: "#7b3fbf"
monogram: "Ph"
iconSlug: "immich"
tags: ["Remplace Google Photos","🌍 · open-source"]
lang: "fr"
---
## what
Deux façons de garder vos photos sans les confier à Google ou Apple.
## why
<b>Ente</b> est un service géré, chiffré de bout en bout, avec reconnaissance et recherche
faites sur l'appareil : simple et sûr. <b>Immich</b> est un clone de Google Photos que vous{' '}
<b>hébergez vous-même</b> (recherche par visages et objets incluse), à réserver à ceux qui
savent gérer un serveur.
## who
Ente pour tous ; Immich pour les auto-hébergeurs. Attention : Immich n'est pas chiffré de bout
en bout, sa sécurité dépend de votre serveur.
## install
<ol>
<li>
Ente : installez l'application, créez un compte, activez la sauvegarde automatique de votre
pellicule.
</li>
<li>
Immich : déployez-le sur votre serveur (voir section 10) via Docker, puis connectez
l'application mobile.
</li>
</ol>
+39
View File
@@ -0,0 +1,39 @@
---
name: "Pi-hole · AdGuard Home"
slug: "pihole"
category: "dns"
difficulty: "advanced"
url: "https://pi-hole.net"
color: "#96060c"
monogram: "π"
iconSlug: "pihole"
tags: ["Blocage tout le réseau","🌍 · open-source"]
lang: "fr"
---
## what
Un bloqueur de publicités et de traqueurs qui protège <b>tout votre réseau à la fois</b>,
installé sur un petit ordinateur comme un Raspberry Pi.
## why
Là où uBlock Origin protège un navigateur, Pi-hole filtre <b>chaque appareil</b> de la maison, y
compris ceux où l'on ne peut pas installer d'extension : téléphones, téléviseurs connectés,
objets connectés. Les pubs et traqueurs sont bloqués au niveau du DNS, avant même de se charger.{' '}
<b>AdGuard Home</b> est une alternative à l'interface plus moderne.
## who
Les foyers qui veulent nettoyer tous leurs appareils d'un coup, et qui ont (ou veulent) un
Raspberry Pi ou un petit serveur.
## install
<ol>
<li>Sur un Raspberry Pi (ou un conteneur), lancez l'installeur officiel de Pi-hole.</li>
<li>
Dans les réglages de votre box/routeur, indiquez l'adresse du Pi-hole comme serveur DNS.
</li>
<li>Désormais, tous les appareils connectés à votre réseau sont filtrés automatiquement.</li>
</ol>
+41
View File
@@ -0,0 +1,41 @@
---
name: "PocketPal AI"
slug: "pocketpal"
category: "ia"
difficulty: "intermediate"
license: "MIT"
url: "https://pocketpal.dev"
color: "#6b4eff"
monogram: "Pp"
tags: ["Remplace ChatGPT sur mobile","📱 · 100% local"]
lang: "fr"
---
## what
Une application pour faire tourner des modèles d'IA directement sur votre téléphone, entièrement
hors ligne.
## why
Vos requêtes ne quittent jamais le téléphone : aucun compte, aucun cloud, aucune télémétrie, et
ça marche en mode avion. Open source (MIT), l'appli télécharge des modèles <b>GGUF</b> (Gemma,
Qwen, Phi, Llama) depuis Hugging Face et les exécute sur le CPU, le GPU ou le NPU de l'appareil.
Le pendant mobile d'Ollama et LM Studio.
## who
Tous ceux qui dégainent ChatGPT sur leur téléphone pour des questions personnelles et ne veulent
pas les confier à un serveur américain.
## install
<ol>
<li>
Installez <b>PocketPal</b> depuis l'App Store ou Google Play (ou compilez les sources).
</li>
<li>
Ouvrez <b>Models</b>, choisissez un modèle adapté à votre téléphone et téléchargez-le.
</li>
<li>Chargez-le et discutez : tout tourne hors ligne, sur l'appareil.</li>
</ol>
+32
View File
@@ -0,0 +1,32 @@
---
name: "Proton Drive"
slug: "protondrive"
category: "stockage"
difficulty: "beginner"
url: "https://proton.me/drive"
color: "#6d4aff"
monogram: "Pd"
iconSlug: "protondrive"
tags: ["Remplace Google Drive / iCloud","🇨🇭 · E2EE"]
lang: "fr"
---
## what
Un cloud chiffré de bout en bout pour vos fichiers et vos photos, intégré à Proton.
## why
Contrairement à Google Drive ou iCloud, Proton ne peut pas lire vos fichiers : ils sont chiffrés
avant de quitter votre appareil. Juridiction suisse, même compte que Proton Mail.
## who
Tout le monde, comme remplacement direct et simple du cloud de Google ou d'Apple.
## install
<ol>
<li>Activez Proton Drive dans votre compte Proton et installez l'application.</li>
<li>Activez la sauvegarde automatique de vos photos, puis désactivez celle de Google/Apple.</li>
</ol>
+43
View File
@@ -0,0 +1,43 @@
---
name: "Proton Mail"
slug: "protonmail"
category: "email"
difficulty: "beginner"
url: "https://proton.me/mail"
color: "#6d4aff"
monogram: "Pm"
iconSlug: "protonmail"
tags: ["Remplace Gmail","🇨🇭 · open-source · OpenPGP"]
lang: "fr"
---
## what
Une boîte mail chiffrée qui remplace Gmail ou Outlook, et le compte pivot de tout l'écosystème
Proton.
## why
Basé en Suisse, hors juridiction des « Five Eyes ». Compatible OpenPGP : chiffrement automatique
entre comptes Proton et vers les autres services PGP.
## who
Tout le monde. C'est le meilleur point de départ pour quitter Google (voir section 05 pour le
reste de la suite).
## install
<ol>
<li>
Créez un compte sur <b>proton.me</b> (offre gratuite suffisante pour commencer).
</li>
<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>
+25
View File
@@ -0,0 +1,25 @@
---
name: "Proton Pass"
slug: "protonpass"
category: "motsdepasse"
difficulty: "beginner"
url: "https://proton.me/pass"
color: "#6d4aff"
monogram: "Pp"
tags: ["Avec alias e-mail","🇨🇭 · open-source"]
lang: "fr"
---
## what
Le gestionnaire de mots de passe de l'écosystème Proton, avec des alias e-mail jetables
intégrés.
## why
Idéal si vous êtes déjà chez Proton : un seul compte pour le mail, le cloud et les mots de
passe. Ses alias e-mail vous évitent de donner votre vraie adresse partout.
## who
Ceux qui veulent tout regrouper chez Proton.
+40
View File
@@ -0,0 +1,40 @@
---
name: "Proton VPN"
slug: "protonvpn"
category: "vpn"
difficulty: "beginner"
url: "https://protonvpn.com"
color: "#6d4aff"
monogram: "Pv"
iconSlug: "protonvpn"
tags: ["Offre gratuite honnête","🇨🇭 · open-source"]
lang: "fr"
---
## what
Le VPN de l'écosystème Proton, avec une véritable offre gratuite (rare et honnête).
## why
Basé en Suisse, audité, open-source. Son offre gratuite est sans publicité, sans revente de
données et sans limite de volume (le nombre de pays est simplement réduit). C'est la porte
d'entrée idéale pour un débutant, surtout si vous utilisez déjà Proton Mail.
## who
Débutants, et toute personne déjà dans l'écosystème Proton qui veut un seul compte pour tout.
## install
<ol>
<li>
Rendez-vous sur <b>protonvpn.com</b> et connectez-vous avec votre compte Proton (ou créez-en
un).
</li>
<li>Installez l'application (Windows, macOS, Linux, Android, iOS).</li>
<li>
Utilisez « Quick Connect » ou choisissez un pays, puis activez le kill switch et NetShield
(blocage des pubs et traqueurs).
</li>
</ol>
+38
View File
@@ -0,0 +1,38 @@
---
name: "Quad9"
slug: "quad9"
category: "dns"
difficulty: "beginner"
url: "https://quad9.net"
color: "#1a5fb4"
monogram: "9"
iconSlug: "quad9"
tags: ["Anti-maliciel","🇨🇭 à but non lucratif"]
lang: "fr"
---
## what
Un résolveur DNS chiffré, suisse et à but non lucratif, qui bloque en plus les domaines
malveillants connus.
## why
Juridiction suisse, pas de conservation de votre adresse IP, protection contre l'hameçonnage et
les logiciels malveillants. Un excellent réglage « installer et oublier ».
## who
Tout le monde, en particulier ceux qui veulent la simplicité et une bonne juridiction.
## install
<ol>
<li>
Sur Android : Réglages &gt; Réseau &gt; <b>DNS privé</b> &gt; saisissez <b>dns.quad9.net</b>.
</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>
+27
View File
@@ -0,0 +1,27 @@
---
name: "Qubes OS · postmarketOS"
slug: "qubes"
category: "os"
difficulty: "advanced"
url: "https://www.qubes-os.org"
color: "#3874d8"
monogram: "Qb"
iconSlug: "qubesos"
tags: ["Cloisonnement","🌍 · open-source"]
lang: "fr"
---
## what
Deux systèmes de niche : Qubes cloisonne votre PC en machines virtuelles étanches ; postmarketOS
fait tourner du Linux sur d'anciens téléphones.
## why
Qubes isole chaque activité (travail, banque, navigation risquée) dans son propre compartiment :
une compromission n'atteint pas le reste. postmarketOS prolonge la vie de téléphones abandonnés
par leur constructeur.
## who
Utilisateurs experts avec un modèle de menace très élevé.
+39
View File
@@ -0,0 +1,39 @@
---
name: "DuckDuckGo · Brave Search · SearXNG"
slug: "search"
category: "navigateur"
difficulty: "beginner"
url: "https://duckduckgo.com"
color: "#de5833"
monogram: "🔎"
iconSlug: "duckduckgo"
tags: ["Remplace Google Search"]
lang: "fr"
---
## what
Des moteurs de recherche qui ne profilent pas chaque requête, contrairement à Google.
## why
DuckDuckGo et Brave Search ne conservent pas d'historique lié à votre identité. Startpage vous
sert des résultats Google sans le pistage. SearXNG va plus loin : c'est un métamoteur{' '}
<b>auto-hébergeable</b>, la souveraineté jusque dans la barre de recherche.
## who
Tout le monde. SearXNG vise ceux qui veulent héberger leur propre moteur.
## install
<ol>
<li>
Dans les réglages de votre navigateur, changez le moteur par défaut pour DuckDuckGo, Brave
Search ou Startpage.
</li>
<li>
Pour SearXNG : utilisez une instance publique (annuaire sur searx.space) ou hébergez la vôtre
(voir section 10).
</li>
</ol>
+40
View File
@@ -0,0 +1,40 @@
---
name: "Session"
slug: "session"
category: "messagerie"
difficulty: "intermediate"
license: "GPL-3.0"
url: "https://getsession.org"
color: "#178a5a"
monogram: "Se"
iconSlug: "session"
tags: ["Sans numéro","🌍 · open-source"]
lang: "fr"
---
## what
Un chiffrement à la Signal, mais sans numéro de téléphone et avec un routage en oignon.
## why
Votre identité est un « Session ID » aléatoire. Le trafic passe par un réseau décentralisé
(Lokinet) qui réduit fortement les métadonnées.
## who
Quand un numéro de téléphone est un risque. Écosystème plus petit, à réserver aux échanges qui
comptent.
## install
<ol>
<li>
Installez depuis <b>getsession.org</b> (toutes plateformes).
</li>
<li>
« Create account » génère votre Session ID et une <b>phrase de récupération</b>.
</li>
<li>Notez la phrase de récupération sur papier : c'est la seule clé de votre compte.</li>
<li>Partagez votre Session ID (ou QR) à vos contacts.</li>
</ol>
+43
View File
@@ -0,0 +1,43 @@
---
name: "Signal"
slug: "signal"
category: "messagerie"
difficulty: "beginner"
license: "AGPL-3.0"
url: "https://signal.org"
color: "#3a76f0"
monogram: "S"
iconSlug: "signal"
tags: ["Remplace WhatsApp","🇺🇸 · open-source"]
lang: "fr"
---
## what
Une messagerie chiffrée pour vos textos, appels, visios et groupes. C'est le remplaçant direct
de WhatsApp, en aussi simple.
## why
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 conservée, et l'équipe a promis de quitter l'UE
plutôt que d'installer un scan.
## who
Tout le monde, dès aujourd'hui. C'est le premier geste et le plus utile. Seule contrainte : un
numéro de téléphone, que l'on peut masquer derrière un nom d'utilisateur.
## install
<ol>
<li>
Installez depuis <b>signal.org</b> (App Store, Google Play, ou l'APK direct sur Android).
</li>
<li>Confirmez votre numéro par SMS, puis choisissez un code PIN.</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>
+43
View File
@@ -0,0 +1,43 @@
---
name: "SimpleX Chat"
slug: "simplex"
category: "messagerie"
difficulty: "intermediate"
url: "https://simplex.chat"
color: "#1e63d0"
monogram: "SX"
iconSlug: "simplex"
tags: ["Sans identifiant","🌍 · open-source"]
lang: "fr"
---
## what
Une messagerie chiffrée sans aucun compte, sans numéro et sans identifiant. On se connecte en
partageant un lien ou un QR code.
## why
Par conception, même les serveurs ignorent qui parle à qui. C'est aujourd'hui l'option la plus
protectrice contre la corrélation des métadonnées.
## who
Comptes pseudonymes, contacts sensibles, haut risque. Un peu plus jeune que Signal, avec
quelques aspérités.
## install
<ol>
<li>
Installez depuis <b>simplex.chat</b> (F-Droid, Google Play, App Store ou APK).
</li>
<li>
Choisissez un nom d'affichage <b>local</b> (jamais votre vrai nom : il ne quitte pas votre
appareil).
</li>
<li>
Pour ajouter un contact, partagez un <b>lien d'invitation à usage unique</b> ou un QR code.
</li>
<li>Activez le verrou par code, et (avancé) configurez vos propres serveurs SMP.</li>
</ol>
+27
View File
@@ -0,0 +1,27 @@
---
name: "Matrix Synapse"
slug: "synapse"
category: "selfhost"
difficulty: "advanced"
license: "AGPL-3.0"
url: "https://element.io/server"
color: "#0dbd8b"
monogram: "Mx"
iconSlug: "matrix"
tags: ["Votre serveur de chat","🌍 · open-source"]
lang: "fr"
---
## what
Votre propre serveur de messagerie Matrix, sur lequel tournent Element et les appels chiffrés.
## why
En hébergeant le serveur, vous contrôlez aussi les <b>métadonnées</b> (qui parle à qui, quand),
le point faible de Matrix en fédération. C'est la messagerie communautaire dont vous êtes le
seul maître.
## who
Communautés, collectifs, rédactions qui veulent leur espace fédéré indépendant.
+36
View File
@@ -0,0 +1,36 @@
---
name: "Tails"
slug: "tails"
category: "os"
difficulty: "advanced"
license: "GPL-3.0"
url: "https://tails.net"
color: "#56347c"
monogram: "Ta"
iconSlug: "tails"
tags: ["Clé USB amnésique","🌍 · tout via Tor"]
lang: "fr"
---
## what
Un système qui démarre depuis une clé USB, fait passer tout le trafic par Tor et ne laisse{' '}
<b>aucune trace</b> à l'extinction.
## why
À l'arrêt, tout est oublié : c'est « amnésique ». Idéal pour un travail sensible et ponctuel sur
un ordinateur qui n'est pas le vôtre, sans rien y laisser.
## who
Lanceurs d'alerte, journalistes, sources. L'outil de référence pour les missions à haut risque.
## install
<ol>
<li>
Téléchargez Tails sur <b>tails.net</b> et suivez l'assistant pour créer la clé USB.
</li>
<li>Redémarrez l'ordinateur sur cette clé. Utilisez-le, puis éteignez : tout disparaît.</li>
</ol>
+43
View File
@@ -0,0 +1,43 @@
---
name: "Tailscale · Headscale · WireGuard"
slug: "tailscale"
category: "vpn"
difficulty: "advanced"
license: "BSD-3-Clause"
url: "https://tailscale.com"
color: "#242424"
monogram: "Ts"
iconSlug: "tailscale"
tags: ["Accès privé à son serveur","🌍 · open-source"]
lang: "fr"
---
## what
Un réseau privé chiffré qui relie vos appareils à votre serveur, sans jamais l'exposer sur
l'Internet public.
## why
Au lieu d'ouvrir des ports (et de vous faire attaquer), <b>Tailscale</b> crée un tunnel
WireGuard entre vos seuls appareils autorisés : votre serveur n'a aucune surface d'attaque
publique. <b>Headscale</b> est la version que vous hébergez vous-même, pour ne dépendre d'aucun
tiers. <b>WireGuard</b> seul offre le contrôle total, au prix d'une configuration manuelle.
## who
Tout auto-hébergeur. C'est la façon sûre d'atteindre son Nextcloud ou ses photos depuis
l'extérieur.
## install
<ol>
<li>
Installez Tailscale sur votre serveur et sur votre téléphone/PC, connectez-les au même compte.
</li>
<li>
Vos appareils se voient aussitôt sur un réseau privé ; accédez à votre serveur par son adresse
Tailscale.
</li>
<li>Pour zéro dépendance, remplacez le serveur de coordination par Headscale, auto-hébergé.</li>
</ol>
+45
View File
@@ -0,0 +1,45 @@
---
name: "Tor Browser"
slug: "tor"
category: "tor"
difficulty: "advanced"
license: "BSD-3-Clause"
url: "https://www.torproject.org"
color: "#7d4698"
monogram: "Tor"
iconSlug: "torbrowser"
tags: ["Anonymat réseau","🌍 · open-source"]
lang: "fr"
---
## what
Un réseau qui sépare qui vous êtes de ce que vous faites en ligne, accessible via le Tor
Browser.
## why
Votre trafic rebondit à travers plusieurs relais chiffrés dans le monde : aucun ne connaît à la
fois votre identité et votre destination. C'est l'outil des journalistes et des sources, et le
moyen d'atteindre un site bloqué (par exemple pour télécharger un VPN censuré).
## who
Profils à haut risque, et quiconque doit dissocier son activité de son adresse IP réelle. À
utiliser ponctuellement, pas comme navigateur de tous les jours.
## install
<ol>
<li>
Téléchargez le Tor Browser sur <b>torproject.org</b> (Windows, macOS, Linux, Android).
</li>
<li>
Lancez-le et cliquez « Se connecter ». Si Tor est bloqué dans votre pays, activez un « pont »
(bridge).
</li>
<li>
Naviguez sans maximiser la fenêtre, sans installer d'extension, et{' '}
<b>sans vous connecter à vos vrais comptes</b>.
</li>
</ol>
+40
View File
@@ -0,0 +1,40 @@
---
name: "Tuta"
slug: "tuta"
category: "email"
difficulty: "beginner"
license: "GPL-3.0"
url: "https://tuta.com"
color: "#b01e2e"
monogram: "Tu"
iconSlug: "tuta"
tags: ["Remplace Gmail","🇩🇪 · open-source · post-quantique"]
lang: "fr"
---
## what
Une messagerie e-mail allemande, entièrement open-source et sans aucun service Google.
## why
Elle chiffre aussi <b>l'objet et une partie des métadonnées</b>, avec un chiffrement résistant à
l'informatique quantique. Juridiction UE.
## who
Ceux qui veulent le maximum de métadonnées chiffrées et du 100 % libre. Elle n'utilise pas PGP :
pour écrire chiffré à l'extérieur, on protège le message par mot de passe.
## install
<ol>
<li>
Créez un compte sur <b>tuta.com</b> et installez l'application (disponible sur F-Droid).
</li>
<li>
Pour écrire à un non-utilisateur de façon chiffrée : cliquez sur le cadenas et définissez un
mot de passe, transmis à votre correspondant par un autre canal.
</li>
<li>Activez la double authentification.</li>
</ol>
+36
View File
@@ -0,0 +1,36 @@
---
name: "uBlock Origin"
slug: "ublock"
category: "navigateur"
difficulty: "beginner"
url: "https://ublockorigin.com"
color: "#7a0d0d"
monogram: "uB"
iconSlug: "ublockorigin"
tags: ["Extension","🌍 · open-source"]
lang: "fr"
---
## what
L'extension de navigateur qui bloque publicités, traqueurs et scripts espions. Le geste le plus
rentable, en dix secondes.
## why
La publicité en ligne est le principal vecteur de surveillance et de profilage. La bloquer coupe
l'essentiel du pistage, accélère les pages et réduit les risques de logiciels malveillants.
## who
Tout le monde, sur Firefox comme sur les navigateurs Chromium.
## install
<ol>
<li>
Ouvrez le magasin d'extensions de votre navigateur et cherchez « uBlock Origin » (l'original,
par Raymond Hill).
</li>
<li>Installez-le. Il fonctionne aussitôt, sans réglage nécessaire.</li>
</ol>
+38
View File
@@ -0,0 +1,38 @@
---
name: "Jitsi Meet · Element Call"
slug: "visio"
category: "boiteaoutils"
difficulty: "beginner"
url: "https://jitsi.org"
color: "#1d6fb8"
monogram: "Vi"
iconSlug: "jitsi"
tags: ["Remplace Zoom / Meet","🌍 · open-source"]
lang: "fr"
---
## what
Des outils de visioconférence sans compte ni installation lourde, pour remplacer Zoom, Google
Meet ou Teams.
## why
<b>Jitsi Meet</b> se lance dans le navigateur, sans compte, et peut être auto-hébergé (c'est
aussi la base de Brave Talk). <b>Element Call</b> est chiffré de bout en bout et fédéré via
Matrix. Pour un appel simple et totalement chiffré, les appels <b>Signal</b> font aussi le
travail.
## who
Tout le monde. Sur un serveur Jitsi public, les appels à plusieurs sont chiffrés en transport ;
le chiffrement de bout en bout est en option.
## install
<ol>
<li>
Jitsi : allez sur meet.jit.si, créez un nom de salon, partagez le lien. Rien à installer.
</li>
<li>Element Call : depuis l'application Element (Matrix), lancez un appel dans un salon.</li>
</ol>
+44
View File
@@ -0,0 +1,44 @@
---
name: "Votre propre site web"
slug: "website"
category: "selfhost"
difficulty: "intermediate"
url: "https://astro.build"
color: "#7a4ddb"
monogram: "www"
tags: ["Résister au bannissement"]
lang: "fr"
---
## what
Un site à vous, sur votre nom de domaine, où votre contenu ne dépend d'aucune plateforme.
## why
Le jour où un réseau supprime ou suspend votre compte, tout votre travail disparaît. Un site
personnel est votre point d'ancrage : vos articles y restent, archivés et à jour, quoi qu'il
arrive. C'est exactement ce que font les comptes d'information prudents. Un site statique
(généré par Hugo, Astro ou Jekyll) suffit et se publie presque gratuitement ; pour un blog
dynamique, un WordPress ou un Ghost auto-hébergé fait l'affaire.
## who
Comptes d'information, créateurs, militants, quiconque publie et craint la censure de
plateforme.
## install
<ol>
<li>
Achetez un nom de domaine (idéalement chez un registraire respectueux de la vie privée).
</li>
<li>
Choisissez un générateur de site statique (Astro, Hugo) et hébergez le résultat, ou installez
WordPress/Ghost sur votre serveur.
</li>
<li>
Renvoyez-y systématiquement depuis vos réseaux : votre audience vous retrouve même si un
compte tombe.
</li>
</ol>
+39
View File
@@ -0,0 +1,39 @@
---
name: "YubiKey · Nitrokey"
slug: "yubikey"
category: "deuxfa"
difficulty: "intermediate"
url: "https://www.yubico.com"
color: "#84bd00"
monogram: "Yk"
iconSlug: "yubico"
tags: ["Clé matérielle FIDO2"]
lang: "fr"
---
## what
Une petite clé physique (format USB, souvent avec NFC) qui prouve votre identité d'un simple
contact du doigt.
## why
C'est la 2FA la plus solide : elle résiste à l'hameçonnage, car la clé vérifie l'adresse réelle
du site avant de répondre. <b>Nitrokey</b> est l'alternative à matériel et micrologiciel
entièrement ouverts. Un faux site ne peut pas vous piéger.
## who
Ceux qui veulent le maximum de sécurité sur leurs comptes clés. Achetez-en <b>deux</b> (une
principale, une de secours).
## install
<ol>
<li>Procurez-vous deux clés sur yubico.com ou nitrokey.com.</li>
<li>
Dans les réglages de sécurité de chaque compte (e-mail, gestionnaire de mots de passe…),
ajoutez une « clé de sécurité » ou « passkey ». Enregistrez vos deux clés.
</li>
<li>Gardez la clé de secours dans un lieu sûr, séparé.</li>
</ol>
+43
View File
@@ -0,0 +1,43 @@
---
name: "YunoHost · Umbrel · Start9"
slug: "yunohost"
category: "selfhost"
difficulty: "advanced"
license: "AGPL-3.0"
url: "https://yunohost.org"
color: "#e29100"
monogram: "Yh"
tags: ["Serveur clé en main","🌍 · open-source"]
lang: "fr"
---
## what
Des systèmes qui transforment un vieux PC ou un Raspberry Pi en serveur personnel, avec un
catalogue d'applications à installer en quelques clics.
## why
L'auto-hébergement fait peur, mais ces outils font le gros du travail. <b>YunoHost</b> gère même
votre propre e-mail et une authentification unique. <b>Umbrel</b> offre un magasin de plus de
300 applications, très prisé des amateurs de vie privée et de Bitcoin. <b>Start9</b> chiffre les
sauvegardes et donne une adresse Tor à chaque service par défaut.
## who
Les profils avancés prêts à consacrer un après-midi à monter leur propre cloud. C'est la parade
structurelle la plus radicale à Chat Control.
## install
<ol>
<li>
Récupérez un vieil ordinateur, un Raspberry Pi, ou louez un petit serveur (VPS) dans un pays
protecteur.
</li>
<li>Installez YunoHost, Umbrel ou Start9 en suivant leur guide officiel.</li>
<li>Depuis le catalogue, installez Nextcloud, un serveur Matrix, une galerie photo, etc.</li>
<li>
Accédez-y en privé avec Tailscale (section VPN) plutôt que d'exposer le serveur au public.
</li>
</ol>
+76
View File
@@ -0,0 +1,76 @@
# Guide de rédaction — fiches d'outils FR/créole
## Format d'une fiche (`content/outils/<slug>.md`)
```markdown
---
name: "Signal"
slug: "signal"
category: "messagerie" # slug d'une catégorie de content/categories.json
difficulty: "beginner" # beginner | intermediate | advanced
license: "AGPL-3.0" # identifiant SPDX exact (spdx.org/licenses)
url: "https://signal.org"
color: "#3a76f0" # couleur du monogramme (décorative)
monogram: "S"
tags: ["Remplace WhatsApp","🇺🇸 · open-source"]
lang: "fr"
---
## what
À quoi sert l'outil, en deux phrases maximum.
## why
Pourquoi il compte pour la souveraineté numérique.
## who
Pour qui, et à quel moment du parcours.
## install
<ol><li>Étapes concrètes, numérotées.</li></ol>
```
Les valeurs du frontmatter sont encodées en JSON (guillemets doubles).
Le corps accepte Markdown et HTML simple ; il est rendu au build, rien n'est
interprété côté client. **Le build échoue si une fiche est invalide** — c'est
le contrat de validation (ADR-001).
## Ton éditorial
- **Militant mais chaleureux, ancré Caraïbe.** Pas de langage corporate, pas
de peur gratuite : on explique le geste, on donne le pouvoir d'agir.
- Tutoiement. Phrases courtes. Le premier mot de chaque section porte le sens
(motif en F, doctrine §1.1).
- `what` dit le service rendu, `why` dit l'enjeu politique/technique, `who`
donne la porte d'entrée (« commence ici si… »), `install` est actionnable
sans autre lecture.
- Exemples et localisation ancrés territoire — jamais de Lorem ipsum
hexagonal (doctrine §4.2).
## Créole guadeloupéen (gcf)
- Les **micro-textes de l'interface** (boutons, filtres, états vides, marqueur
de fin) vivent dans `frontend/src/lib/i18n/gcf.json`. C'est la couche à
plus fort impact affectif pour le plus faible coût (doctrine §4.2) — elle
est traduite en premier.
- ⚠️ **Relecture par un locuteur natif requise** : les chaînes gcf actuelles
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.
- La traduction intégrale des fiches est un chantier éditorial séparé :
le format prévu est un fichier jumeau `content/outils/gcf/<slug>.md` avec
`lang: "gcf"` (le chargeur l'ignorera tant que la locale de contenu n'est
pas activée).
- Termes fixés : « zouti » (outil), « Ou pa manké ayen » (fin de liste),
« Chwazi zouti ou » (héros), niveaux « Débitan / Entèmédia / Avansé ».
## Ajouter une catégorie
Ajouter une entrée à `content/categories.json` (`slug`, `title`, `order`,
`part`). La navigation n'affiche que 4 catégories avant la divulgation
progressive (Hick-Hyman, doctrine §1.3) : l'ordre `order` décide lesquelles.
## Vérifications avant merge
- [ ] `license` est un identifiant SPDX valide
- [ ] `difficulty` reflète l'expérience réelle d'installation (pas le prestige)
- [ ] Chaque section respecte sa question (what/why/who/install)
- [ ] `pnpm build` passe (validation du frontmatter incluse)
+72
View File
@@ -0,0 +1,72 @@
# Design system OKI — exitchatcontrol.org
Toutes les valeurs visuelles vivent dans
[`frontend/src/styles/tokens.css`](../frontend/src/styles/tokens.css).
Les composants Svelte consomment les tokens ; **jamais de valeur brute**
(couleur, taille) dans un composant (doctrine §5.2).
## Architecture CSS
Cascade explicite par `@layer` ([app.css](../frontend/src/app.css)) :
```
reset → tokens → base → layouts → components → utilities
```
- `reset.css` — reset moderne minimal (~40 lignes), inclut `prefers-reduced-motion`.
- `tokens.css` — custom properties : couleurs `oklch()`, typo fluide `clamp()`, espacements, interaction.
- `base.css` — HTML sémantique nu : typo, liens, focus visible, boutons.
- `layouts.css` — primitives Every Layout : `.stack`, `.cluster`, `.center`, `.card-grid` (container queries, pas de media queries de largeur d'écran).
- Le style propre à chaque composant vit dans son `<style>` Svelte (scopé à la compilation).
## Couleurs
En `oklch()` : la luminance perceptuelle rend les contrastes WCAG calculables
(méthode modus-vivendi, doctrine §4.3). `light-dark()` + `color-scheme` gèrent
le mode sombre sans duplication.
| Token | Rôle | Note contraste |
|---|---|---|
| `--oki-ink` / `--oki-surface` | texte / fond | ≥ 7:1 (AAA) dans les deux modes |
| `--oki-ink-2` | texte secondaire | ≥ 4.5:1 (AA) |
| `--oki-jaune` `--oki-vert` `--oki-rouge` `--oki-noir` | identité panafricaine | décoratif — jamais porteur d'information seul |
| `--accent` / `--accent-ink` | accent actif / accent texte | `--accent-ink` ≥ 4.5:1 sur `--oki-surface` |
| `--diff-*` / `--diff-*-ink` | difficulté (fond / texte) | les variantes `-ink` sont AA sur `--oki-surface` |
**Règle absolue** : la difficulté n'est jamais codée par la couleur seule —
`DifficultyBadge` = forme (●/◆/▲) + texte + couleur (doctrine §1.1, ~8 % de
déficience de vision des couleurs).
## Typographie
- Corps : **Atkinson Hyperlegible** 400 (SIL OFL, conçue pour la lisibilité).
- Display (titres, marque) : **Fraunces** 700 (SIL OFL).
- 2 fichiers WOFF2 auto-hébergés (`static/fonts/`), ~35 Ko au total,
`font-display: swap` — jamais de Google Fonts (doctrine §5.3).
- Échelle modulaire ratio 1.25, fluide par `clamp()` : `--text-0``--text-5`.
- `--measure: 65ch` sur `p, li` et `.prose` (doctrine §2.2 : 4575 caractères).
- Tout en `rem` : la taille utilisateur est respectée.
## Interaction
- `--tap-target: 48px` : tout élément interactif (boutons, liens de nav,
filtres, switcher) a une hauteur minimale de 48 px (Fitts, doctrine §1.3).
- `:focus-visible` global : anneau 3 px `--accent`, offset 2 px.
- Durées `--duration-1/2` + `--ease-out` ; annulées par `prefers-reduced-motion`.
## Composants
| Composant | Rôle | Principe doctrinal |
|---|---|---|
| `DifficultyBadge` | niveau (forme + texte + couleur) | §1.1 — jamais couleur seule |
| `ToolCard` | fiche condensée, vrai `<a>` couvrant | §4.3 — pas de div cliquable |
| `CategoryNav` | 4 catégories visibles + `<details>` | §1.3 Hick-Hyman, divulgation progressive, sans JS |
| `SearchBar` | recherche instantanée, raccourci `/` | §2.2 — le clavier est roi |
| `LanguageSwitcher` | FR / Kréyòl, persisté localStorage | §4.2 — bilinguisme |
| `EndMarker` | « Ou pa manké ayen » en fin de liste | §1.5, §3 — point d'arrêt explicite |
## Test des 50 ms (doctrine §1.1)
L'identité OKI est reconnaissable avant toute lecture : bandes panafricaines
vert/jaune/rouge dans la marque d'en-tête, soulignés et bordures jaune OKI,
titres Fraunces, créole visible dès le héros (« Chwazi zouti ou »).
+40
View File
@@ -0,0 +1,40 @@
# exitchatcontrol × OKI — frontend
Annuaire souverain d'outils libres. SvelteKit 2 + Svelte 5, site 100 %
prérendu, zéro CDN tiers, zéro tracker. Voir [`ADR.md`](../ADR.md) pour la
décision d'architecture et `../docs/` pour le design system et le guide
de contenu.
## Prérequis
Node ≥ 22.12, pnpm.
## Commandes
```sh
pnpm install
pnpm migrate # (re)génère content/ depuis l'ancien site Astro (src/)
pnpm dev # serveur de développement
pnpm build # build statique dans build/ (prérend les 58 pages)
pnpm preview # sert le build localement
pnpm check # svelte-check (types + a11y)
```
## Déploiement
`build/` est un site statique : n'importe quel serveur de fichiers convient
(nginx du `docker/` existant, YunoHost `my_webapp`, etc.). Aucune dépendance
cloud, aucun service à maintenir — un miroir = une copie du dossier.
## Arborescence
```
src/styles/ tokens, reset, base, layouts (cascade @layer)
src/lib/i18n/ fr.json + gcf.json typés, store de langue (runes)
src/lib/components/ ToolCard, DifficultyBadge, CategoryNav, SearchBar,
LanguageSwitcher, EndMarker
src/lib/content/ chargeur build-time des fichiers ../content/
src/routes/ / (landing), /outils (annuaire filtrable), /outils/[slug]
static/fonts/ 2 WOFF2 auto-hébergés (Atkinson Hyperlegible, Fraunces)
scripts/ migrate-tools.mjs
```
+31
View File
@@ -0,0 +1,31 @@
{
"name": "exitchatcontrol-oki",
"private": true,
"version": "4.0.0-alpha.1",
"type": "module",
"description": "Exit Chat Control × OKI — annuaire souverain d'outils libres. SvelteKit statique, zéro tracker, zéro CDN.",
"license": "MIT",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"migrate": "node scripts/migrate-tools.mjs"
},
"engines": {
"node": ">=22.12"
},
"devDependencies": {
"@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@types/node": "^26.1.1",
"marked": "^16.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^7.0.0"
}
}
+1076
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
/**
* Migration des données du site Astro vers les fichiers plats OKI (ADR-001,
* mission §7). Extrait les 55 ToolCard des MDX français + métadonnées de
* src/data/{tools,directory}.json et produit :
* content/outils/<slug>.md (frontmatter JSON-par-ligne + sections HTML)
* content/categories.json (catégories dérivées des sections sources)
*
* Idempotent : relançable, écrase la sortie. Usage : pnpm migrate
*/
import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const SRC = path.join(ROOT, 'src')
const OUT = path.join(ROOT, 'content')
const DIFFICULTY = { 1: 'beginner', 2: 'intermediate', 3: 'advanced' }
const tools = JSON.parse(await readFile(path.join(SRC, 'data', 'tools.json'), 'utf8'))
const directory = JSON.parse(await readFile(path.join(SRC, 'data', 'directory.json'), 'utf8'))
const toolById = new Map(tools.map((t) => [t.id, t]))
const dirById = new Map(directory.map((d) => [d.id, d]))
function parseFrontmatter(raw) {
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
if (!m) return [{}, raw]
const meta = {}
for (const line of m[1].split('\n')) {
const i = line.indexOf(':')
if (i === -1) continue
const key = line.slice(0, i).trim()
let value = line.slice(i + 1).trim()
if (/^'.*'$/.test(value)) value = value.slice(1, -1)
meta[key] = /^\d+$/.test(value) ? Number(value) : value
}
return [meta, raw.slice(m[0].length)]
}
/** Extrait le contenu d'un slot (Fragment/ol/ul) dans un bloc ToolCard. */
function slot(block, name) {
const re = new RegExp(
`<(Fragment|ol|ul)([^>]*slot="${name}"[^>]*)>([\\s\\S]*?)</\\1>`
)
const m = block.match(re)
if (!m) return null
// Les listes gardent leur balise (le HTML passe tel quel dans le markdown) ;
// les Fragments sont déballés.
return m[1] === 'Fragment' ? m[3].trim() : `<${m[1]}>${m[3]}</${m[1]}>`.trim()
}
/** Tags = textes des <span class="tool-tag"> hors difficulté (déjà structurée). */
function extractTags(block) {
const html = slot(block, 'tags') ?? ''
const tags = [...html.matchAll(/<span class="tool-tag">([\s\S]*?)<\/span>/g)]
.map((m) => m[1].replace(/\s+/g, ' ').trim())
.filter((t) => !/^[🟢🟡🔴]/u.test(t))
return tags
}
/** Difficulté : `level` de tools.json, sinon l'emoji du tag MDX (🟢/🟡/🔴). */
function difficulty(tool, block) {
if (DIFFICULTY[tool.level]) return DIFFICULTY[tool.level]
const html = slot(block, 'tags') ?? ''
if (html.includes('🟢')) return 'beginner'
if (html.includes('🟡')) return 'intermediate'
if (html.includes('🔴')) return 'advanced'
console.warn(`${tool.id} : difficulté introuvable — « intermediate » par défaut`)
return 'intermediate'
}
const fm = (key, value) => (value == null ? null : `${key}: ${JSON.stringify(value)}`)
const sectionsDir = path.join(SRC, 'content', 'sections', 'fr')
const files = (await readdir(sectionsDir)).filter((f) => f.endsWith('.mdx')).sort()
await mkdir(path.join(OUT, 'outils'), { recursive: true })
const categories = []
const seen = new Set()
let written = 0
for (const file of files) {
const raw = await readFile(path.join(sectionsDir, file), 'utf8')
const [meta, body] = parseFrontmatter(raw)
const blocks = [...body.matchAll(/<ToolCard tool="([^"]+)"[^>]*>([\s\S]*?)<\/ToolCard>/g)]
if (blocks.length === 0) continue
categories.push({
slug: meta.id,
title: meta.title,
order: meta.order,
part: meta.part
})
for (const [, toolId, block] of blocks) {
const tool = toolById.get(toolId)
if (!tool) {
console.warn(`${file} : ToolCard "${toolId}" absent de tools.json — ignoré`)
continue
}
const slug = toolId.replace(/^t-/, '')
if (seen.has(slug)) {
console.warn(`${slug} : doublon (déjà émis) — ignoré dans ${file}`)
continue
}
seen.add(slug)
const dir = dirById.get(slug)
const nameOverride = slot(block, 'name')
const front = [
fm('name', nameOverride ?? tool.name),
fm('slug', slug),
fm('category', meta.id),
fm('difficulty', difficulty(tool, block)),
fm('license', dir?.license),
fm('url', tool.url),
fm('color', tool.color),
fm('monogram', tool.monogram),
fm('iconSlug', tool.iconSlug),
fm('tags', extractTags(block)),
fm('lang', 'fr')
]
.filter(Boolean)
.join('\n')
const sections = [
['what', slot(block, 'what')],
['why', slot(block, 'why')],
['who', slot(block, 'who')],
['install', slot(block, 'install')],
['extra', slot(block, 'extra')]
]
.filter(([, html]) => html)
.map(([key, html]) => `## ${key}\n\n${html}`)
.join('\n\n')
await writeFile(path.join(OUT, 'outils', `${slug}.md`), `---\n${front}\n---\n\n${sections}\n`)
written++
}
}
await writeFile(path.join(OUT, 'categories.json'), JSON.stringify(categories, null, 2) + '\n')
console.log(`${written} fiches écrites dans content/outils/, ${categories.length} catégories`)
const missing = tools.filter((t) => !seen.has(t.id.replace(/^t-/, '')))
if (missing.length) {
console.warn(`⚠ outils de tools.json sans ToolCard fr : ${missing.map((t) => t.id).join(', ')}`)
}
+14
View File
@@ -0,0 +1,14 @@
/*
* app.css — ordre de cascade explicite et auditable (doctrine §5.2).
* Chaque couche est déclarée AVANT tout import : la position dans la
* cascade est ici, pas dans l'ordre des fichiers.
*/
@layer reset, tokens, base, layouts, components, utilities;
/* @font-face n'est pas concerné par les couches */
@import './styles/fonts.css';
@import './styles/reset.css' layer(reset);
@import './styles/tokens.css' layer(tokens);
@import './styles/base.css' layer(base);
@import './styles/layouts.css' layer(layouts);
+12
View File
@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#17181f" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,101 @@
<script lang="ts">
import { browser } from '$app/environment'
import { page } from '$app/state'
import { t } from '$lib/i18n/index.svelte'
interface NavCategory {
slug: string
title: string
}
let { categories }: { categories: NavCategory[] } = $props()
// Hick-Hyman (doctrine §1.3) : ≤ 5 entrées visibles, le reste en
// divulgation progressive (<details>, fonctionne sans JS).
const VISIBLE = 4
let visible = $derived(categories.slice(0, VISIBLE))
let rest = $derived(categories.slice(VISIBLE))
// searchParams non lisibles au prerender : garde `browser`
let current = $derived(browser ? page.url.searchParams.get('cat') : null)
function href(slug: string | null): string {
return slug ? `/outils?cat=${slug}` : '/outils'
}
</script>
<nav aria-label={t('directory.filterCategory')} class="catnav">
<ul class="cluster">
<li><a href={href(null)} aria-current={current === null ? 'page' : undefined}>{t('directory.allCategories')}</a></li>
{#each visible as cat (cat.slug)}
<li>
<a href={href(cat.slug)} aria-current={current === cat.slug ? 'page' : undefined}>{cat.title}</a>
</li>
{/each}
{#if rest.length > 0}
<li>
<details>
<summary>+ {rest.length}</summary>
<ul class="stack">
{#each rest as cat (cat.slug)}
<li>
<a href={href(cat.slug)} aria-current={current === cat.slug ? 'page' : undefined}>{cat.title}</a>
</li>
{/each}
</ul>
</details>
</li>
{/if}
</ul>
</nav>
<style>
ul {
list-style: none;
padding: 0;
}
a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
a[aria-current='page'] {
background: var(--oki-jaune);
border-color: var(--oki-jaune);
color: var(--oki-noir);
font-weight: 700;
}
details {
position: relative;
}
summary {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px dashed var(--oki-line);
border-radius: var(--radius-2);
cursor: pointer;
}
details ul {
position: absolute;
z-index: 2;
inset-inline-start: 0;
margin-block-start: var(--space-1);
padding: var(--space-2);
background: var(--oki-surface);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
min-inline-size: 16rem;
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.15);
}
</style>
@@ -0,0 +1,36 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
// Jamais la couleur seule : forme + texte + couleur (doctrine §1.1 —
// ~8 % de déficience de vision des couleurs).
const SHAPES = { beginner: '●', intermediate: '◆', advanced: '▲' } as const
let { difficulty }: { difficulty: keyof typeof SHAPES } = $props()
</script>
<span class="badge badge-{difficulty}">
<span aria-hidden="true">{SHAPES[difficulty]}</span>
{t(`difficulty.${difficulty}`)}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-0);
font-weight: 600;
padding: 0.15em 0.6em;
border-radius: var(--radius-2);
border: 1px solid currentColor;
}
.badge-beginner {
color: var(--diff-beginner-ink);
}
.badge-intermediate {
color: var(--diff-intermediate-ink);
}
.badge-advanced {
color: var(--diff-advanced-ink);
}
</style>
@@ -0,0 +1,34 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
</script>
<!-- Marqueur de fin explicite : restitue le point d'arrêt naturel que le
scroll infini supprime (doctrine §1.5 et §3.2). -->
<p class="end" role="status">
<strong>{t('directory.endOfList')}</strong>
<span>{t('directory.endOfListSub')}</span>
</p>
<style>
.end {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
text-align: center;
padding-block: var(--space-5);
border-top: 4px double var(--oki-line);
margin-block-start: var(--space-5);
}
strong {
font-family: var(--font-display);
font-size: var(--text-2);
color: var(--accent-ink);
}
span {
color: var(--oki-ink-2);
font-size: var(--text-0);
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts">
import { getLocale, setLocale, locales, localeNames, t } from '$lib/i18n/index.svelte'
</script>
<!-- Bascule explicite FR / Kréyòl, persistée en localStorage (mission §4.3).
Amélioration progressive : sans JS, le site reste lisible en français. -->
<div class="switch" role="group" aria-label={t('lang.switchLabel')}>
{#each locales as locale (locale)}
<button
type="button"
aria-pressed={getLocale() === locale}
onclick={() => setLocale(locale)}
>
{localeNames[locale]}
</button>
{/each}
</div>
<style>
.switch {
display: inline-flex;
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
overflow: hidden;
}
button {
border: none;
border-radius: 0;
background: transparent;
min-height: var(--tap-target);
font-size: var(--text-0);
}
button[aria-pressed='true'] {
background: var(--oki-jaune);
color: var(--oki-noir);
font-weight: 700;
}
</style>
@@ -0,0 +1,43 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte'
let { value = $bindable('') }: { value?: string } = $props()
let input: HTMLInputElement
// raccourci « / » desktop (doctrine §2.2 — le clavier est roi)
function onkeydown(e: KeyboardEvent) {
if (e.key === '/' && document.activeElement !== input) {
const target = e.target as HTMLElement
if (target.closest('input, textarea, [contenteditable]')) return
e.preventDefault()
input.focus()
}
}
</script>
<svelte:window {onkeydown} />
<p class="search">
<label>
<span class="visually-hidden">{t('search.label')}</span>
<input
bind:this={input}
bind:value
type="search"
placeholder={t('search.placeholder')}
autocomplete="off"
/>
</label>
</p>
<style>
input {
inline-size: 100%;
max-inline-size: 28rem;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
background: var(--oki-surface);
}
</style>
@@ -0,0 +1,99 @@
<script lang="ts">
import DifficultyBadge from './DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
interface CardTool {
name: string
slug: string
difficulty: 'beginner' | 'intermediate' | 'advanced'
license?: string
color?: string
monogram?: string
tags: string[]
excerpt: string
}
let { tool }: { tool: CardTool } = $props()
</script>
<article class="card">
<header class="cluster">
<span class="monogram" style:--tool-color={tool.color} aria-hidden="true">
{tool.monogram ?? tool.name.slice(0, 2)}
</span>
<h3><a href="/outils/{tool.slug}">{tool.name}</a></h3>
</header>
<p class="excerpt"><span class="visually-hidden">{t('tool.why')} : </span>{tool.excerpt}</p>
<footer class="cluster">
<DifficultyBadge difficulty={tool.difficulty} />
{#if tool.license}
<span class="meta">{tool.license}</span>
{/if}
{#each tool.tags as tag (tag)}
<span class="meta">{tag}</span>
{/each}
</footer>
</article>
<style>
.card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--oki-line);
border-left: 4px solid var(--oki-jaune);
border-radius: var(--radius-2);
background: var(--oki-surface);
height: 100%;
}
.monogram {
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border-radius: var(--radius-2);
background: var(--tool-color, var(--oki-surface-2));
color: white;
font-weight: 700;
font-size: var(--text-0);
}
h3 {
font-size: var(--text-2);
}
/* toute la carte est cliquable via le pseudo-élément du lien —
un vrai <a>, pas de div cliquable (doctrine §4.3) */
h3 a {
text-decoration: none;
}
h3 a::after {
content: '';
position: absolute;
inset: 0;
}
h3 a:focus-visible::after {
outline: var(--focus-ring);
outline-offset: -3px;
border-radius: var(--radius-2);
}
.excerpt {
color: var(--oki-ink-2);
font-size: var(--text-0);
flex-grow: 1;
}
.meta {
font-size: var(--text-0);
color: var(--oki-ink-2);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: 0.1em 0.6em;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
/**
* 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.
*/
import { readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { marked } from 'marked'
export type Difficulty = 'beginner' | 'intermediate' | 'advanced'
export interface ToolSections {
what?: string
why?: string
who?: string
install?: string
extra?: string
}
export interface Tool {
name: string
slug: string
category: string
difficulty: Difficulty
license?: string
url: string
color?: string
monogram?: string
iconSlug?: string
tags: string[]
/** extrait texte brut de « why », pour les cartes */
excerpt: string
sections: ToolSections
}
export interface Category {
slug: string
title: string
order: number
part: number
}
const CONTENT = path.resolve(process.cwd(), '..', 'content')
const SECTION_KEYS = ['what', 'why', 'who', 'install', 'extra'] as const
function parseFrontmatter(raw: string): [Record<string, unknown>, string] {
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
if (!m) throw new Error('frontmatter manquant')
const meta: Record<string, unknown> = {}
for (const line of m[1].split('\n')) {
const i = line.indexOf(':')
if (i === -1) continue
// les valeurs sont encodées en JSON par le script de migration
meta[line.slice(0, i).trim()] = JSON.parse(line.slice(i + 1).trim())
}
return [meta, raw.slice(m[0].length)]
}
function parseSections(body: string): ToolSections {
const sections: ToolSections = {}
const parts = body.split(/^## (\w+)\s*$/m)
for (let i = 1; i < parts.length; i += 2) {
const key = parts[i] as (typeof SECTION_KEYS)[number]
if (SECTION_KEYS.includes(key)) {
sections[key] = marked.parse(parts[i + 1].trim(), { async: false })
}
}
return sections
}
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' '
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (e) => ENTITIES[e])
.replace(/[ \t\r\n]+/g, ' ')
.trim()
}
function loadTool(file: string): Tool {
const raw = readFileSync(path.join(CONTENT, 'outils', file), 'utf8')
const [meta, body] = parseFrontmatter(raw)
const sections = parseSections(body)
const why = stripHtml(sections.why ?? sections.what ?? '')
return {
...(meta as unknown as Omit<Tool, 'sections' | 'excerpt'>),
excerpt: why.length > 160 ? `${why.slice(0, 157).trimEnd()}` : why,
sections
}
}
let cache: { tools: Tool[]; categories: Category[] } | null = null
export function loadContent() {
if (cache) return cache
const categories = (
JSON.parse(readFileSync(path.join(CONTENT, 'categories.json'), 'utf8')) as Category[]
).sort((a, b) => a.order - b.order)
const order = new Map(categories.map((c, i) => [c.slug, i]))
const tools = readdirSync(path.join(CONTENT, 'outils'))
.filter((f) => f.endsWith('.md'))
.map(loadTool)
.sort(
(a, b) =>
(order.get(a.category) ?? 99) - (order.get(b.category) ?? 99) ||
a.name.localeCompare(b.name, 'fr')
)
cache = { tools, categories }
return cache
}
+63
View File
@@ -0,0 +1,63 @@
{
"site": {
"name": "Exit Chat Control",
"tagline": "Reprends le contrôle de ton internet",
"description": "Annuaire d'outils libres pour la souveraineté numérique — messagerie chiffrée, VPN, auto-hébergement, OS libres."
},
"nav": {
"home": "Accueil",
"directory": "Annuaire",
"contribute": "Contribuer",
"skipToContent": "Aller au contenu"
},
"hero": {
"title": "Chwazi zouti ou",
"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"
},
"directory": {
"title": "Annuaire des outils",
"intro": "Chaque outil est libre, vérifié, et classé par difficulté. Commence par le vert.",
"filterCategory": "Catégorie",
"filterDifficulty": "Difficulté",
"allCategories": "Toutes les catégories",
"allDifficulties": "Toutes",
"results": "outils",
"resultOne": "outil",
"empty": "Ayen pa maché èvè filtè-lasa — essaie d'élargir les filtres.",
"endOfList": "Ou pa manké ayen",
"endOfListSub": "Tu es au bout de la liste — rien ne se recharge dans ton dos.",
"prevPage": "Page précédente",
"nextPage": "Page suivante",
"page": "Page"
},
"tool": {
"what": "À quoi ça sert",
"why": "Pourquoi c'est important",
"who": "Pour qui, quand",
"install": "Installer et utiliser",
"officialSite": "Site officiel",
"sourceCode": "Code source",
"license": "Licence",
"category": "Catégorie",
"backToDirectory": "Retour à l'annuaire"
},
"difficulty": {
"beginner": "Débutant",
"intermediate": "Intermédiaire",
"advanced": "Avancé"
},
"search": {
"label": "Rechercher un outil",
"placeholder": "Rechercher… (touche /)",
"noResults": "Aucun outil trouvé"
},
"lang": {
"switchLabel": "Langue",
"switchTo": "Passer en"
},
"footer": {
"license": "Contenu et code libres (MIT). Zéro tracker, zéro CDN, zéro cookie.",
"contribute": "Proposer un outil ou une correction"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"site": {
"name": "Exit Chat Control",
"tagline": "Pran kontwòl entènèt a-w",
"description": "Lis a zouti lib pou libèté niméwik — mésajri chifré, VPN, oto-ébèjman, sistèm lib."
},
"nav": {
"home": "Lakou",
"directory": "Lis a zouti",
"contribute": "Pòté mannèv",
"skipToContent": "Alé dirèk asi kontni-la"
},
"hero": {
"title": "Chwazi zouti ou",
"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"
},
"directory": {
"title": "Lis a zouti",
"intro": "Chak zouti lib, vérifyé, é rangé pa difikilté. Koumansé èvè vèw-la.",
"filterCategory": "Katégori",
"filterDifficulty": "Difikilté",
"allCategories": "Tout katégori",
"allDifficulties": "Tout",
"results": "zouti",
"resultOne": "zouti",
"empty": "Ayen pa maché èvè filtè-lasa — èséyé louvri filtè a-w.",
"endOfList": "Ou pa manké ayen",
"endOfListSub": "Ou rivé an bout a lis-la — ayen pa ka richajé dèyè do a-w.",
"prevPage": "Paj avan",
"nextPage": "Paj apré",
"page": "Paj"
},
"tool": {
"what": "Pou ki sa i sèwvi",
"why": "Poukisa sa enpòwtan",
"who": "Pou kimoun, kitan",
"install": "Enstalé é sèwvi",
"officialSite": "Sit ofisyèl",
"sourceCode": "Kòd sous",
"license": "Lisans",
"category": "Katégori",
"backToDirectory": "Viré an lis-la"
},
"difficulty": {
"beginner": "Débitan",
"intermediate": "Entèmédia",
"advanced": "Avansé"
},
"search": {
"label": "Chèché on zouti",
"placeholder": "Chèché… (touch /)",
"noResults": "Pon zouti pa touvé"
},
"lang": {
"switchLabel": "Lang",
"switchTo": "Pasé an"
},
"footer": {
"license": "Kontni é kòd lib (MIT). Pon trakè, pon CDN, pon cookie.",
"contribute": "Pwopozé on zouti oben on koreksyon"
}
}
+54
View File
@@ -0,0 +1,54 @@
import fr from './fr.json'
import gcf from './gcf.json'
import { browser } from '$app/environment'
import { defaultLocale, type Locale } from './locales'
export { locales, localeNames, defaultLocale, type Locale } from './locales'
type Dict = typeof fr
// gcf est vérifié par TypeScript contre la forme du français : une clé
// manquante est une erreur de build, pas un fallback silencieux (ADR-001).
const dicts: Record<Locale, Dict> = { fr, gcf }
const STORAGE_KEY = 'oki-lang'
function initialLocale(): Locale {
if (!browser) return defaultLocale
const saved = localStorage.getItem(STORAGE_KEY)
return saved === 'gcf' || saved === 'fr' ? saved : defaultLocale
}
// Rune d'état partagée (Svelte 5) : la langue de l'interface.
// Le HTML prérendu est en fr ; le créole est une amélioration progressive
// (persistance localStorage — mission §4.3, doctrine §4.2).
const state = $state({ locale: initialLocale() })
export function getLocale(): Locale {
return state.locale
}
export function setLocale(locale: Locale) {
state.locale = locale
if (browser) {
localStorage.setItem(STORAGE_KEY, locale)
document.documentElement.lang = locale
}
}
function lookup(dict: Dict, path: string): unknown {
return path.split('.').reduce<unknown>((node, key) => {
if (node && typeof node === 'object' && key in node) {
return (node as Record<string, unknown>)[key]
}
return undefined
}, dict)
}
/** Chaîne d'interface pour `path` dans la langue courante (réactif). */
export function t(path: string): string {
const hit = lookup(dicts[state.locale], path) ?? lookup(dicts[defaultLocale], path)
if (typeof hit !== 'string') {
throw new Error(`Clé i18n manquante « ${path} » (locale : ${state.locale})`)
}
return hit
}
+11
View File
@@ -0,0 +1,11 @@
// gcf = créole guadeloupéen (ISO 639-3, valide BCP 47).
// La langue du cœur d'OKI : les micro-textes créoles activent les circuits
// de confiance avant toute lecture consciente (doctrine §4.2).
export const locales = ['fr', 'gcf'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'fr'
export const localeNames: Record<Locale, string> = {
fr: 'Français',
gcf: 'Kréyòl'
}
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import '../app.css'
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'
import { t } from '$lib/i18n/index.svelte'
let { children } = $props()
</script>
<a class="skip-link" href="#main">{t('nav.skipToContent')}</a>
<header class="center">
<div class="cluster bar">
<a class="brand" href="/">
<span class="brand-mark" aria-hidden="true"></span>
{t('site.name')}
</a>
<nav aria-label={t('nav.home')}>
<ul class="cluster">
<li><a href="/outils">{t('nav.directory')}</a></li>
<li>
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
{t('nav.contribute')}
</a>
</li>
</ul>
</nav>
<LanguageSwitcher />
</div>
</header>
<main id="main" class="center stack" style="--gap: var(--space-5)">
{@render children()}
</main>
<footer class="center">
<p>{t('footer.license')}</p>
<p>
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
{t('footer.contribute')}
</a>
</p>
</footer>
<style>
.bar {
justify-content: space-between;
padding-block: var(--space-3);
border-bottom: 4px solid var(--oki-jaune);
margin-block-end: var(--space-4);
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--text-2);
text-decoration: none;
color: var(--oki-ink);
min-height: var(--tap-target);
}
/* marque OKI : les trois bandes panafricaines — identité du territoire
dès le premier écran, test des 50 ms (doctrine §1.1 et §4.2) */
.brand-mark {
inline-size: 0.9rem;
block-size: 1.6rem;
border-radius: 2px;
background: linear-gradient(
to bottom,
var(--oki-vert) 0 33%,
var(--oki-jaune) 33% 66%,
var(--oki-rouge) 66% 100%
);
}
nav ul {
list-style: none;
padding: 0;
}
nav a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-2);
text-decoration: none;
color: var(--oki-ink);
}
nav a:hover {
text-decoration: underline;
text-decoration-color: var(--oki-jaune);
text-decoration-thickness: 3px;
}
main {
padding-block-end: var(--space-6);
}
footer {
border-top: 1px solid var(--oki-line);
padding-block: var(--space-4);
color: var(--oki-ink-2);
font-size: var(--text-0);
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// Tout le site est prérendu (ADR-001) : HTML statique servi par nginx,
// un lien fonctionne sans JS (doctrine §4.4).
export const prerender = true
+11
View File
@@ -0,0 +1,11 @@
import { loadContent } from '$lib/content/tools.server'
export function load() {
const { tools, categories } = loadContent()
return {
categories,
toolCount: tools.length,
// les premiers gestes : outils débutant, un par catégorie
starters: tools.filter((t) => t.difficulty === 'beginner').slice(0, 3)
}
}
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import ToolCard from '$lib/components/ToolCard.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
</script>
<svelte:head>
<title>{t('site.name')}{t('site.tagline')}</title>
<meta name="description" content={t('site.description')} />
</svelte:head>
<!-- Valeur livrée en < 5 s (doctrine §2.1) : le héros dit quoi, pour qui,
et donne UNE action primaire (Von Restorff, doctrine §1.2). -->
<section class="hero stack">
<h1>{t('hero.title')}</h1>
<p class="sub">{t('hero.subtitle')}</p>
<p><a class="cta" href="/outils">{t('hero.cta')} · {data.toolCount} {t('directory.results')}</a></p>
</section>
<section class="stack">
<h2>{t('directory.intro')}</h2>
<div class="card-grid">
<ul>
{#each data.starters as tool (tool.slug)}
<li><ToolCard {tool} /></li>
{/each}
</ul>
</div>
</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>
.hero {
padding-block: var(--space-5);
}
.hero h1 {
color: var(--accent-ink);
}
.sub {
font-size: var(--text-2);
color: var(--oki-ink-2);
max-width: var(--measure);
}
/* une seule action primaire par écran (Von Restorff, doctrine §1.2) */
.cta {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-4);
background: var(--oki-jaune);
color: var(--oki-noir);
font-weight: 700;
border-radius: var(--radius-2);
text-decoration: none;
}
.cta:hover {
filter: brightness(1.05);
}
.cats {
list-style: none;
padding: 0;
}
.cats a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
.cats a:hover {
border-color: var(--oki-jaune);
}
</style>
@@ -0,0 +1,10 @@
import { loadContent } from '$lib/content/tools.server'
export function load() {
const { tools, categories } = loadContent()
return {
categories,
// seules les données de carte partent au client (budget JS, doctrine §4.4)
tools: tools.map(({ sections: _sections, ...card }) => card)
}
}
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { browser } from '$app/environment'
import { page } from '$app/state'
import CategoryNav from '$lib/components/CategoryNav.svelte'
import SearchBar from '$lib/components/SearchBar.svelte'
import ToolCard from '$lib/components/ToolCard.svelte'
import EndMarker from '$lib/components/EndMarker.svelte'
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
let query = $state('')
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'] as const
// Chaque filtre vit dans l'URL (query param) : partageable (mission §5.1).
// Le HTML prérendu montre la liste complète ; le filtrage est une
// amélioration progressive côté client (ADR-001). Les searchParams ne
// sont pas lisibles au prerender, d'où la garde `browser`.
let cat = $derived(browser ? page.url.searchParams.get('cat') : null)
let diff = $derived(browser ? page.url.searchParams.get('diff') : null)
let filtered = $derived(
data.tools.filter(
(tool) =>
(!cat || tool.category === cat) &&
(!diff || tool.difficulty === diff) &&
(!query ||
`${tool.name} ${tool.tags.join(' ')} ${tool.excerpt}`
.toLocaleLowerCase('fr')
.includes(query.toLocaleLowerCase('fr')))
)
)
function diffHref(d: string | null): string {
const params = new URLSearchParams(browser ? page.url.searchParams : undefined)
if (cat) params.set('cat', cat)
if (d) params.set('diff', d)
else params.delete('diff')
const qs = params.toString()
return qs ? `/outils?${qs}` : '/outils'
}
</script>
<svelte:head>
<title>{t('directory.title')}{t('site.name')}</title>
<meta name="description" content={t('directory.intro')} />
</svelte:head>
<header class="stack">
<h1>{t('directory.title')}</h1>
<p>{t('directory.intro')}</p>
</header>
<div class="stack filters">
<SearchBar bind:value={query} />
<CategoryNav categories={data.categories} />
<nav aria-label={t('directory.filterDifficulty')}>
<ul class="cluster">
<li>
<a href={diffHref(null)} aria-current={diff === null ? 'page' : undefined}>
{t('directory.allDifficulties')}
</a>
</li>
{#each DIFFICULTIES as d (d)}
<li>
<a href={diffHref(d)} aria-current={diff === d ? 'page' : undefined}>
<DifficultyBadge difficulty={d} />
</a>
</li>
{/each}
</ul>
</nav>
</div>
<section class="stack" aria-live="polite">
<p class="count">
{filtered.length}
{filtered.length === 1 ? t('directory.resultOne') : t('directory.results')}
</p>
{#if filtered.length === 0}
<p class="empty">{t('directory.empty')}</p>
{:else}
<div class="card-grid">
<ul>
{#each filtered as tool (tool.slug)}
<li><ToolCard {tool} /></li>
{/each}
</ul>
</div>
{/if}
<EndMarker />
</section>
<style>
.filters {
--gap: var(--space-3);
}
nav ul {
list-style: none;
padding: 0;
}
nav a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
nav a[aria-current='page'] {
background: var(--oki-surface-2);
border-color: var(--oki-ink);
font-weight: 700;
}
.count {
color: var(--oki-ink-2);
font-size: var(--text-0);
}
.empty {
padding: var(--space-5);
border: 1px dashed var(--oki-line);
border-radius: var(--radius-2);
text-align: center;
color: var(--oki-ink-2);
}
</style>
@@ -0,0 +1,14 @@
import { error } from '@sveltejs/kit'
import { loadContent } from '$lib/content/tools.server'
export function entries() {
return loadContent().tools.map(({ slug }) => ({ slug }))
}
export function load({ params }: { params: { slug: string } }) {
const { tools, categories } = loadContent()
const tool = tools.find((t) => t.slug === params.slug)
if (!tool) error(404)
const category = categories.find((c) => c.slug === tool.category)
return { tool, category }
}
@@ -0,0 +1,132 @@
<script lang="ts">
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
let tool = $derived(data.tool)
const SECTION_ORDER = ['what', 'why', 'who', 'install', 'extra'] as const
// « extra » n'a pas d'intitulé i18n dédié : rattaché à l'installation
const HEADINGS: Record<string, string> = {
what: 'tool.what',
why: 'tool.why',
who: 'tool.who',
install: 'tool.install',
extra: 'tool.install'
}
let jsonLd = $derived(
`<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: tool.name,
url: tool.url,
license: tool.license ? `https://spdx.org/licenses/${tool.license}` : undefined,
applicationCategory: data.category?.title
})}${'<'}/script>`
)
</script>
<svelte:head>
<title>{tool.name}{t('site.name')}</title>
<meta name="description" content={tool.excerpt} />
<meta property="og:title" content={tool.name} />
<meta property="og:description" content={tool.excerpt} />
<meta property="og:type" content="article" />
<!-- eslint-disable-next-line svelte/no-at-html-tags — JSON-LD généré au build depuis nos propres données -->
{@html jsonLd}
</svelte:head>
<nav aria-label={t('tool.backToDirectory')}>
<a href="/outils">{t('tool.backToDirectory')}</a>
</nav>
<article class="stack" style="--gap: var(--space-4)">
<header class="stack">
<div class="cluster">
<span class="monogram" style:--tool-color={tool.color} aria-hidden="true">
{tool.monogram ?? tool.name.slice(0, 2)}
</span>
<h1>{tool.name}</h1>
</div>
<div class="cluster meta">
<DifficultyBadge difficulty={tool.difficulty} />
{#if data.category}
<a href="/outils?cat={data.category.slug}">{data.category.title}</a>
{/if}
{#if tool.license}
<span>{t('tool.license')} : {tool.license}</span>
{/if}
{#each tool.tags as tag (tag)}
<span>{tag}</span>
{/each}
</div>
<p>
<a class="cta" href={tool.url} rel="external">{t('tool.officialSite')}</a>
</p>
</header>
{#each SECTION_ORDER as key (key)}
{#if tool.sections[key]}
<section class="prose stack">
<h2>{t(HEADINGS[key])}</h2>
<!-- HTML produit au build par marked depuis notre propre contenu versionné -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="stack">{@html tool.sections[key]}</div>
</section>
{/if}
{/each}
</article>
<style>
.monogram {
display: grid;
place-items: center;
inline-size: 3.5rem;
block-size: 3.5rem;
border-radius: var(--radius-2);
background: var(--tool-color, var(--oki-surface-2));
color: white;
font-weight: 700;
}
.meta {
color: var(--oki-ink-2);
font-size: var(--text-0);
}
.meta > * {
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
padding: 0.15em 0.6em;
}
.meta a {
color: inherit;
}
.cta {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-4);
background: var(--oki-jaune);
color: var(--oki-noir);
font-weight: 700;
border-radius: var(--radius-2);
text-decoration: none;
}
section h2 {
font-size: var(--text-3);
border-bottom: 3px solid var(--oki-jaune);
display: inline-block;
padding-block-end: var(--space-1);
}
nav a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
}
</style>
+99
View File
@@ -0,0 +1,99 @@
/*
* base.css — HTML sémantique nu (doctrine §4.3 : l'accessibilité est
* d'abord un problème de HTML correct).
*/
body {
font-family: var(--font-text);
font-size: var(--text-1);
line-height: var(--leading);
color: var(--oki-ink);
background: var(--oki-surface);
}
h1,
h2,
h3 {
font-family: var(--font-display);
line-height: 1.15;
text-wrap: balance;
}
h1 {
font-size: var(--text-5);
}
h2 {
font-size: var(--text-4);
}
h3 {
font-size: var(--text-2);
}
p,
li {
max-width: var(--measure);
}
a {
color: var(--accent-ink);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
a:hover {
text-decoration-thickness: 2px;
}
/* focus visible sur TOUT élément interactif (doctrine §4.3) */
:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-1);
}
button {
min-height: var(--tap-target); /* Fitts, doctrine §1.3 */
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
background: var(--oki-surface-2);
cursor: pointer;
}
code {
font-size: 0.9em;
background: var(--oki-surface-2);
padding: 0.1em 0.35em;
border-radius: var(--radius-1);
}
pre {
overflow-x: auto;
padding: var(--space-3);
background: var(--oki-surface-2);
border-radius: var(--radius-2);
}
pre code {
background: none;
padding: 0;
}
::selection {
background: var(--oki-jaune);
color: var(--oki-noir);
}
/* lien d'évitement clavier */
.skip-link {
position: absolute;
inset-inline-start: var(--space-3);
inset-block-start: var(--space-3);
translate: 0 -300%;
background: var(--oki-surface);
padding: var(--space-2) var(--space-3);
z-index: 10;
}
.skip-link:focus {
translate: 0 0;
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Polices auto-hébergées, servies depuis notre domaine — jamais de Google
* Fonts (doctrine §5.3 : performance, RGPD, souveraineté).
* Budget : 2 fichiers WOFF2 maximum. Le gras du corps est synthétisé.
*/
@font-face {
font-family: 'Atkinson Hyperlegible';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/atkinson-hyperlegible-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/fraunces-latin-700-normal.woff2') format('woff2');
}
+63
View File
@@ -0,0 +1,63 @@
/*
* layouts.css — primitives de composition (approche « Every Layout » :
* des compositions, pas des pages — doctrine §5.2).
* Le responsive passe par container queries, jamais par media queries
* de largeur d'écran (doctrine §2.2 point 6).
*/
.stack {
display: flex;
flex-direction: column;
gap: var(--gap, var(--space-3));
}
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--gap, var(--space-2));
align-items: var(--align, center);
}
.center {
box-sizing: content-box;
max-inline-size: var(--max, 70rem);
margin-inline: auto;
padding-inline: var(--space-3);
}
.prose {
max-inline-size: var(--measure);
}
/* grille de cartes : pilotée par le conteneur, pas par l'écran */
.card-grid {
container-type: inline-size;
}
.card-grid > ul {
list-style: none;
padding: 0;
display: grid;
gap: var(--space-3);
grid-template-columns: 1fr;
}
@container (min-width: 44rem) {
.card-grid > ul {
grid-template-columns: repeat(2, 1fr);
}
}
@container (min-width: 68rem) {
.card-grid > ul {
grid-template-columns: repeat(3, 1fr);
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
+54
View File
@@ -0,0 +1,54 @@
/* Reset moderne minimal (doctrine §5.2 — ~30 lignes, rien de plus). */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
html {
/* jamais de font-size en px sur html : respect de la taille utilisateur (doctrine §4.3) */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
min-height: 100dvh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
img,
picture,
svg,
video {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
p,
h1,
h2,
h3,
h4 {
overflow-wrap: break-word;
}
@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;
}
}
+77
View File
@@ -0,0 +1,77 @@
/*
* tokens.css — design tokens OKI (doctrine §5.2).
* Toute valeur visuelle du site vit ici ; les composants Svelte consomment
* les tokens, jamais de valeur brute (couleur, taille) dans un composant.
* Couleurs en oklch() : luminance perceptuelle maîtrisée => contraste WCAG
* calculable (méthode modus-vivendi, doctrine §4.3).
*/
:root {
color-scheme: light dark;
/* ── Identité panafricaine OKI ─────────────────────────────────────── */
--oki-ink: light-dark(oklch(18% 0.02 260), oklch(94% 0.01 260));
--oki-surface: light-dark(oklch(97% 0.005 90), oklch(14% 0.02 260));
--oki-surface-2: light-dark(oklch(93% 0.008 90), oklch(20% 0.02 260));
--oki-line: light-dark(oklch(85% 0.01 90), oklch(32% 0.02 260));
--oki-ink-2: light-dark(oklch(38% 0.02 260), oklch(75% 0.01 260));
/* Couleurs identitaires (drapeau panafricain — jaune/vert/rouge/noir) */
--oki-jaune: oklch(75% 0.18 85);
--oki-vert: oklch(55% 0.18 145);
--oki-rouge: oklch(55% 0.22 25);
--oki-noir: oklch(15% 0.01 260);
/* Accent principal : le jaune OKI, décliné pour rester AA sur chaque fond */
--accent: light-dark(oklch(52% 0.14 85), var(--oki-jaune));
--accent-ink: light-dark(oklch(35% 0.1 85), oklch(85% 0.16 85));
/* ── Difficulté — jamais portée par la couleur seule (doctrine §1.1) ──
Chaque niveau = couleur + texte + icône dans DifficultyBadge. */
--diff-beginner: oklch(65% 0.15 145);
--diff-intermediate: oklch(75% 0.16 85);
--diff-advanced: oklch(55% 0.18 25);
/* variantes texte, contrastées AA sur --oki-surface */
--diff-beginner-ink: light-dark(oklch(42% 0.14 145), oklch(78% 0.15 145));
--diff-intermediate-ink: light-dark(oklch(48% 0.13 85), oklch(80% 0.15 85));
--diff-advanced-ink: light-dark(oklch(46% 0.18 25), oklch(72% 0.16 25));
/* ── Typographie fluide (clamp, zéro media query — doctrine §5.2) ────
Échelle modulaire ratio 1.25, corps 16px mobile → 18px desktop. */
--text-0: clamp(0.875rem, 0.85rem + 0.15vw, 0.9375rem);
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
--text-2: clamp(1.25rem, 1.17rem + 0.5vw, 1.4rem);
--text-3: clamp(1.56rem, 1.42rem + 0.8vw, 1.75rem);
--text-4: clamp(1.95rem, 1.7rem + 1.4vw, 2.44rem);
--text-5: clamp(2.44rem, 2rem + 2.4vw, 3.4rem);
--font-text: 'Atkinson Hyperlegible', system-ui, sans-serif;
--font-display: 'Fraunces', var(--font-text);
--measure: 65ch; /* largeur de lecture 45-75ch (doctrine §2.2) */
--leading: 1.6;
/* ── Espacement modulaire ────────────────────────────────────────── */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
/* ── Interaction ─────────────────────────────────────────────────── */
--tap-target: 48px; /* Fitts (doctrine §1.3) */
--radius-1: 4px;
--radius-2: 10px;
--duration-1: 120ms;
--duration-2: 240ms;
--ease-out: cubic-bezier(0.2, 0, 0, 1);
--focus-ring: 3px solid var(--accent);
}
@media (prefers-contrast: more) {
:root {
--oki-ink: light-dark(oklch(5% 0.01 260), oklch(99% 0 0));
--oki-line: light-dark(oklch(40% 0.01 260), oklch(70% 0.01 260));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+18
View File
@@ -0,0 +1,18 @@
{
"name": "Exit Chat Control",
"short_name": "ExitCC",
"description": "Annuaire d'outils libres pour la souveraineté numérique",
"lang": "fr",
"start_url": "/",
"display": "standalone",
"background_color": "#17181f",
"theme_color": "#17181f",
"icons": [
{
"src": "/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
import adapter from '@sveltejs/adapter-static'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
// Site 100 % prérendu : servi par nginx sur l'infra OKI, mirrorable
// (ADR-001 ; doctrine §4.4 — HTML rendu serveur, un lien marche sans JS).
adapter: adapter({
fallback: '404.html'
}),
prerender: {
handleHttpError: 'fail'
}
}
}
export default config
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [sveltekit()],
server: {
fs: {
// le contenu éditorial vit à la racine du repo, hors de frontend/
allow: ['..']
}
}
})