chore: état initial avant refonte (atlas SvelteKit v4.0.0)
Snapshot de l'existant avant application du playbook OKI : background shader Three.js, carte graphe 3D, timeline statique.
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
**/build
|
||||
**/.svelte-kit
|
||||
**/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,66 @@
|
||||
# Piège Apache (o2switch) : « Index of /… » au lieu des pages
|
||||
|
||||
Symptôme : le site est déployé sur un hébergement mutualisé Apache (o2switch),
|
||||
la page d'accueil s'affiche, mais **cliquer sur un lien interne affiche
|
||||
« Index of /chatcontrol/quiz »** (un listing de dossier) au lieu de la page.
|
||||
|
||||
## Cause : conflit fichier / dossier
|
||||
|
||||
SvelteKit (adapter-static, `trailingSlash` par défaut = `'never'`) génère :
|
||||
|
||||
```
|
||||
build/
|
||||
├── quiz.html ← la page
|
||||
├── quiz/ ← dossier créé pour __data.json
|
||||
│ └── __data.json
|
||||
├── outils.html
|
||||
├── outils/
|
||||
│ └── …
|
||||
```
|
||||
|
||||
Quand le navigateur demande `/chatcontrol/quiz`, Apache voit qu'un **dossier**
|
||||
`quiz/` existe et redirige (301) vers `/chatcontrol/quiz/`. Comme ce dossier
|
||||
ne contient pas de `index.html`, Apache sert son **autoindex** (le listing).
|
||||
Le fichier `quiz.html`, lui, n'est jamais trouvé.
|
||||
|
||||
Apache donne toujours la priorité au dossier quand les deux existent —
|
||||
ce problème revient à chaque site statique posé sur du mutualisé Apache.
|
||||
|
||||
## Correctif (déjà appliqué dans ce dépôt)
|
||||
|
||||
`frontend/src/routes/+layout.ts` :
|
||||
|
||||
```ts
|
||||
export const trailingSlash = 'always'
|
||||
```
|
||||
|
||||
Chaque page est alors générée **dans** son dossier :
|
||||
|
||||
```
|
||||
build/
|
||||
├── quiz/
|
||||
│ ├── index.html ← la page, servie par Apache
|
||||
│ └── __data.json
|
||||
├── outils/
|
||||
│ ├── index.html
|
||||
│ └── aegis/
|
||||
│ └── index.html
|
||||
```
|
||||
|
||||
Plus de conflit possible : le dossier existe, il contient un `index.html`,
|
||||
Apache le sert.
|
||||
|
||||
## Checklist quand ça arrive
|
||||
|
||||
1. `export const trailingSlash = 'always'` dans le `+layout.ts` racine.
|
||||
2. `pnpm build` → vérifier que `build/quiz/index.html` existe
|
||||
(et non `build/quiz.html`).
|
||||
3. **Vider le dossier distant avant de re-uploader** : les anciens
|
||||
`quiz.html` / `outils.html` et les vieux assets `_app/immutable/` hashés
|
||||
doivent partir, sinon ils traînent indéfiniment.
|
||||
4. Tester `curl -s https://<site>/<page>/` → du HTML, pas « Index of ».
|
||||
|
||||
Note : les liens internes relatifs (`./outils`) provoquent une petite
|
||||
redirection 301 vers l'URL avec `/` final. Normal, invisible pour
|
||||
l'utilisateur. L'éviter exigerait `paths.relative: false`, au prix de la
|
||||
consultation locale des fichiers du build.
|
||||
@@ -0,0 +1,391 @@
|
||||
# UI/UX souveraine — Fondements neuro-cognitifs, doctrine multi-device et stack front-end libre
|
||||
|
||||
**Document de référence OKI** · Rédigé le 06/07/2026 · Destiné à l'archivage (Denote)
|
||||
**Périmètre** : plateformes web OKI (fédérées et éditoriales), applications natives, UI Maskarad
|
||||
**Licence suggérée du document** : CC BY-SA 4.0
|
||||
**Note de fraîcheur** : les fondements neuro-cognitifs sont stables (décennies de littérature). Les versions de frameworks citées (Svelte 5, SvelteKit 2, Tailwind 4, Qt 6.x) correspondent à l'état connu début 2026 — vérifier les numéros de version exacts au moment de l'implémentation, la doctrine reste valable.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. Fondements neuro-cognitifs
|
||||
2. Doctrine par device (mobile, desktop, tablette, montre)
|
||||
3. Éthique attentionnelle — la calm technology comme avantage compétitif
|
||||
4. Adoption en contexte caribéen
|
||||
5. Stack front-end web : Svelte + CSS
|
||||
6. Applications natives : Qt et alternatives libres
|
||||
7. Checklist d'audit OKI
|
||||
8. Application aux projets en cours (fronts fédérés, Maskarad)
|
||||
9. Références
|
||||
|
||||
---
|
||||
|
||||
## 1. Fondements neuro-cognitifs
|
||||
|
||||
### 1.1 La vision : fovéale, saccadique, prédictive
|
||||
|
||||
- **Fovéa** : la vision nette couvre ~2° d'angle visuel (un pouce à bout de bras). Tout le reste est périphérique, flou, sensible au mouvement et au contraste — et sert exclusivement à planifier la prochaine **saccade** (3-4 sauts oculaires par seconde).
|
||||
- **Conséquence** : l'utilisateur ne « voit » pas une page, il la parcourt par sauts. La hiérarchie visuelle (taille, contraste, position, espacement) est le rail des saccades. Ce qui n'est pas sur le rail n'existe pas cognitivement.
|
||||
- **Motifs de balayage** : motif en **F** pour le contenu textuel (deux bandes horizontales en haut, puis descente verticale à gauche — d'où l'importance capitale des premiers mots de chaque ligne/titre), motif en **Z** pour les pages d'accueil peu denses.
|
||||
- **Le contraste prime sur la couleur** pour guider l'attention. ~8 % des hommes ont une déficience de vision des couleurs : ne jamais coder une information par la couleur seule (toujours doubler par forme, texte ou icône).
|
||||
- **Jugement esthétique en 50 ms** (Lindgaard et al., 2006) : la première impression de confiance/qualité se forme avant toute lecture consciente, sur la base de l'harmonie visuelle globale. Elle biaise ensuite tout le reste de l'expérience (effet de halo).
|
||||
|
||||
### 1.2 Attention et mémoire de travail
|
||||
|
||||
- **Système 1 / Système 2** (Kahneman) : ~95 % des interactions se font en mode automatique, rapide, par reconnaissance de motifs. L'utilisateur **ne lit pas, il reconnaît**. Le mode analytique (Système 2) est coûteux, lent, et l'utilisateur l'évite. Toute UI qui exige du Système 2 pour des tâches routinières sera perçue comme « compliquée ».
|
||||
- **Mémoire de travail : 4±1 éléments** (Cowan, révision moderne du 7±2 de Miller). Chaque écran, menu, formulaire qui demande de tenir plus de 4 éléments simultanément en tête génère de la surcharge.
|
||||
- **Théorie de la charge cognitive** (Sweller) : distinguer la charge **intrinsèque** (complexité réelle de la tâche — incompressible), **extrinsèque** (générée par la présentation — à éliminer sans pitié), **germane** (effort d'apprentissage utile — à doser). Le design d'interface est essentiellement une guerre contre la charge extrinsèque.
|
||||
- **Effet Zeigarnik** : les tâches interrompues restent actives en mémoire — exploitable positivement (reprendre où on s'est arrêté, indicateurs de progression) ou toxiquement (notifications qui créent des boucles ouvertes artificielles).
|
||||
- **Effet de position sérielle** : on retient mieux le premier et le dernier élément d'une liste. Placer l'essentiel aux extrémités des menus et des parcours.
|
||||
- **Effet Von Restorff** : l'élément visuellement distinct est mémorisé — un seul bouton d'action primaire par écran, visuellement isolé.
|
||||
- **Règle du pic et de la fin (peak-end, Kahneman)** : une expérience est mémorisée par son moment le plus intense et par sa fin. Soigner les fins de parcours (confirmation de publication, fin d'inscription, fin de session) autant que les débuts.
|
||||
|
||||
### 1.3 Les lois quantitatives de l'interaction
|
||||
|
||||
- **Loi de Fitts** : le temps d'atteinte d'une cible croît avec la distance et décroît avec la taille. Applications : cibles tactiles ≥ 44-48 px ; sur desktop, les bords et coins d'écran sont des cibles « infinies » (le curseur ne peut pas les dépasser) ; regrouper spatialement les actions liées.
|
||||
- **Loi de Hick-Hyman** : le temps de décision croît logarithmiquement avec le nombre d'options. Applications : navigation à 4-5 entrées max, divulgation progressive, réglages par défaut intelligents plutôt que questions.
|
||||
- **Seuil de Doherty (~400 ms)** : sous 400 ms, l'interaction est perçue comme un dialogue fluide (état de flow maintenu) ; au-delà de 1 s, l'attention décroche ; au-delà de 10 s, l'utilisateur est mentalement parti. Entre 1 et 10 s : indicateur de progression obligatoire. La **performance perçue** se travaille aussi : skeleton screens, optimistic UI, transitions qui masquent la latence.
|
||||
- **Loi de Jakob (Nielsen)** : les utilisateurs passent l'essentiel de leur temps ailleurs — ils arrivent avec des schémas mentaux préformés par WhatsApp, Instagram, YouTube. Toute déviation des conventions a un coût cognitif : le dépenser **délibérément** (là où la différenciation a du sens), jamais par accident.
|
||||
- **Loi de Tesler (conservation de la complexité)** : la complexité d'une tâche ne disparaît jamais, elle se déplace. Soit l'interface l'absorbe (travail de conception), soit l'utilisateur la subit. La complexité fédérative (instances, protocoles) doit être absorbée par l'interface, pas exposée à l'inscription.
|
||||
|
||||
### 1.4 Motricité : le pouce comme curseur
|
||||
|
||||
- En tenue à une main (usage mobile majoritaire), le pouce couvre confortablement le **tiers inférieur** de l'écran ; les coins supérieurs sont pénibles et instables.
|
||||
- Précision tactile réelle : ~7-10 mm de zone de contact, d'où le standard 44-48 px avec espacement ≥ 8 px entre cibles.
|
||||
- Les **gestes sont invisibles** donc non découvrables : ils complètent des boutons visibles, ne les remplacent jamais. Exception : les gestes devenus conventions universelles (pincer-zoomer, tirer-rafraîchir, swipe latéral de retour).
|
||||
|
||||
### 1.5 Boucles dopaminergiques et récompense variable
|
||||
|
||||
- Le circuit dopaminergique répond plus fortement à la récompense **imprévisible** qu'à la récompense certaine (conditionnement opérant, Skinner). Les mécaniques pull-to-refresh + contenu aléatoire, scroll infini, compteurs de likes, streaks, sont des programmes de renforcement à ratio variable — littéralement l'architecture des machines à sous.
|
||||
- Le **scroll infini supprime les points d'arrêt naturels** que le cerveau utilise pour décider consciemment de continuer ou partir. La pagination, les marqueurs « vous êtes à jour », les fins de section restituent ce choix.
|
||||
- Position OKI (voir §3) : refus explicite de ces mécaniques, transformé en argument d'adoption.
|
||||
|
||||
---
|
||||
|
||||
## 2. Doctrine par device
|
||||
|
||||
### 2.1 Mobile (le device par défaut)
|
||||
|
||||
**Contexte réel** : sessions courtes (30-90 s), fragmentées, en mobilité, souvent à une main, sur des forfaits data comptés et des réseaux 3G/4G irréguliers (réalité caribéenne).
|
||||
|
||||
Règles :
|
||||
|
||||
1. **Zone du pouce** : actions primaires dans le tiers inférieur (barre d'onglets basse, bouton d'action flottant si justifié) ; le haut de l'écran pour le contexte et les actions rares/destructives.
|
||||
2. **Valeur en 5 secondes** : chaque écran doit livrer sa raison d'être immédiatement, sans lecture préalable.
|
||||
3. **Reprennabilité** : état sauvegardé en continu, retour instantané au point d'interruption, aucune tâche exigeant plus de 2-3 minutes ininterrompues sans point de sauvegarde.
|
||||
4. **Une colonne, un flux** : pas de mise en page multi-colonnes ; hiérarchie verticale stricte.
|
||||
5. **Cibles 44-48 px minimum**, espacement 8 px, texte de corps ≥ 16 px (empêche aussi le zoom automatique d'iOS sur les champs de formulaire).
|
||||
6. **Frugalité data** : lazy loading, AVIF/WebP pour l'image, AV1 pour la vidéo (cohérent avec la stack OKI), pas de police web au-delà de 2 fichiers, budget page < 500 Ko idéal, < 1 Mo maximum.
|
||||
7. **PWA installable + offline-first** : service worker, cache des contenus consultés, file d'attente des actions hors-ligne. Double bénéfice : résilience réseau + **souveraineté** (contournement des stores propriétaires).
|
||||
8. **Formulaires** : le clavier virtuel mange la moitié de l'écran — un champ visible à la fois, types d'input corrects (`inputmode`, `autocomplete`), jamais plus de champs que strictement nécessaire.
|
||||
|
||||
### 2.2 Desktop
|
||||
|
||||
**Contexte réel** : sessions longues, attention soutenue possible, pointeur précis, clavier, écran large. C'est le device des **contributeurs** (modérateurs, créateurs, administrateurs d'instance).
|
||||
|
||||
Règles :
|
||||
|
||||
1. **Largeur de lecture : 45-75 caractères par ligne** (~65 optimal). Au-delà, les saccades de retour à la ligne fatiguent et font perdre la ligne. Contraindre par `max-width` en `ch`.
|
||||
2. **Densité maîtrisée et ajustable** : le desktop peut afficher plus, pas afficher n'importe quoi. Offrir un réglage compact/confortable pour les vues de type liste/tableau.
|
||||
3. **Clavier roi** : navigation complète au clavier (j/k pour les flux comme Mastodon web, / pour la recherche, raccourcis affichés via `?`). Les utilisateurs experts sont ceux qui font vivre une plateforme fédérée — les raccourcis sont un investissement de rétention des contributeurs.
|
||||
4. **Bords et coins de Fitts** : menus et actions fréquentes contre les bords de fenêtre quand c'est pertinent.
|
||||
5. **États de survol riches** (prévisualisation, info-bulles) — inexistants sur tactile, donc jamais porteurs d'information exclusive.
|
||||
6. **Multi-fenêtrage respecté** : l'application doit rester utilisable à 50 % de largeur d'écran (design responsive par container queries, pas par media queries d'appareil — voir §5.2).
|
||||
|
||||
### 2.3 Tablette
|
||||
|
||||
Ni un grand téléphone ni un petit desktop : usage majoritairement **en consommation, tenue à deux mains, pouces sur les bords latéraux**. Actions primaires près des bords gauche/droit, contenu au centre. Cible secondaire pour OKI : la traiter par responsive fluide (container queries) plutôt que par design dédié, sauf pour Maskarad où la lecture de visual novel sur tablette est un cas d'usage premium (paysage, deux zones de pouce pour avancer/menu).
|
||||
|
||||
### 2.4 Montres et périphériques
|
||||
|
||||
Hors périmètre de développement dédié pour OKI à ce stade. Règle d'hygiène : les notifications envoyées par les plateformes doivent être **conçues pour être lues en 2 secondes sur un poignet** (émetteur + verbe + objet), ce qui est de toute façon la bonne discipline de notification (§3).
|
||||
|
||||
### 2.5 Continuité multi-device
|
||||
|
||||
Le même individu utilise plusieurs devices dans la même journée. Exigences : état synchronisé (position de lecture, brouillons), identité unifiée (cohérent avec le chantier IndieWeb), et **parité fonctionnelle raisonnée** — toute fonction essentielle disponible partout, les fonctions de production avancées peuvent rester desktop.
|
||||
|
||||
---
|
||||
|
||||
## 3. Éthique attentionnelle — la calm technology comme avantage compétitif
|
||||
|
||||
Les plateformes propriétaires optimisent des boucles d'engagement compulsif (§1.5). Une plateforme souveraine peut faire le pari inverse et **le dire** :
|
||||
|
||||
1. **Notifications** : groupées par défaut, paramétrables finement, jamais de notification purement ré-engageante (« Untel a aimé une photo que vous pourriez aimer »). Chaque notification = une information actionnable émise par un humain.
|
||||
2. **Points d'arrêt naturels** : pagination ou marqueur « ou pa manké ayen » / « vous êtes à jour » dans les flux. Pas de scroll infini sans fin.
|
||||
3. **Pas de métriques anxiogènes** : compteurs de likes optionnels/masquables, pas de streaks, pas de « vu à ».
|
||||
4. **Pas de dark patterns** : désinscription aussi facile que l'inscription, pas de confirm-shaming, pas d'urgence artificielle, exports de données en un clic (garanti de toute façon par ActivityPub).
|
||||
5. **Technologie périphérique** (Amber Case / Mark Weiser) : l'information non urgente reste en périphérie (badges discrets) et n'interrompt jamais.
|
||||
|
||||
**Traduction en communication** : pour un public 16-25 ans saturé et de plus en plus lucide sur la manipulation attentionnelle, « nos plateformes ne sont pas conçues pour vous retenir » est un argument d'adoption explicite. Neuro-cognitivement : on troque l'engagement compulsif court terme contre la **confiance**, qui est le prédicteur de la rétention volontaire long terme et du bouche-à-oreille.
|
||||
|
||||
---
|
||||
|
||||
## 4. Adoption en contexte caribéen
|
||||
|
||||
### 4.1 Familiarité en surface, différence en profondeur
|
||||
|
||||
Paradoxe central : pour faire adopter le radicalement différent (fédération, souveraineté), l'interface doit être radicalement **familière**. Le Système 1 doit pouvoir utiliser la plateforme sans apprendre ; la différence idéologique se découvre en Système 2, une fois la confiance établie.
|
||||
|
||||
- **Masquer la complexité fédérative à l'entrée** (loi de Tesler) : une instance d'entrée par défaut, inscription < 60 s, le choix d'instance proposé plus tard comme option avancée. L'erreur historique de Mastodon (choisir une instance avant toute valeur perçue = charge cognitive maximale au pire moment) est documentée et évitable.
|
||||
- **Onboarding par la valeur, pas par la pédagogie** : montrer du contenu du territoire dès le premier écran, expliquer la fédération au moment où elle devient visible (première interaction inter-instances), pas avant.
|
||||
|
||||
### 4.2 Langue et code culturel
|
||||
|
||||
- **Bilinguisme français/créole** dans l'interface : la reconnaissance linguistique active les circuits de confiance et d'appartenance avant toute lecture consciente. Le créole dans les micro-textes (boutons, états vides, confirmations) a un impact affectif disproportionné par rapport à son coût.
|
||||
- **Iconographie et visuels du territoire** : une plateforme qui *ressemble* au territoire est catégorisée « pour moi » dans la fenêtre des 50 ms (§1.1). Le travail visuel de Maskarad (Mas, Basse-Terre) est le référentiel de qualité.
|
||||
- **Localisation réelle** : formats de date, exemples, contenus de démonstration ancrés Caraïbe — jamais de Lorem ipsum hexagonal.
|
||||
|
||||
### 4.3 Accessibilité comme rigueur
|
||||
|
||||
- **WCAG 2.2 niveau AA minimum** : contraste 4.5:1 pour le texte courant, 3:1 pour le grand texte et les composants d'interface. Viser AAA (7:1) pour le corps de texte — la méthode modus-vivendi de Protesilaos Stavrou (palette construite *à partir* des ratios de contraste, pas décorée ensuite) est le modèle méthodologique exact : brique par brique appliqué à la couleur.
|
||||
- **Respect des préférences système** : `prefers-color-scheme`, `prefers-reduced-motion`, `prefers-contrast`, taille de police utilisateur (tout en `rem`, jamais de `font-size` en `px` sur `html`).
|
||||
- **HTML sémantique d'abord** : landmarks, hiérarchie de titres, `<button>` pour les actions, focus visible, ordre de tabulation logique. L'accessibilité est massivement un problème de HTML correct avant d'être un problème d'ARIA (« No ARIA is better than bad ARIA »).
|
||||
|
||||
### 4.4 Performance comme premier contact et preuve de respect
|
||||
|
||||
- **Budget : premier rendu < 3 s sur 3G** (seuil de survie), interactif < 5 s.
|
||||
- **HTML rendu côté serveur + amélioration progressive** : le contenu arrive en HTML, le JavaScript enrichit. Un lien doit fonctionner sans JS. (SvelteKit fait cela nativement, voir §5.1.)
|
||||
- **Budgets chiffrés** : JS initial < 100 Ko compressé (idéal < 50), CSS < 50 Ko, page totale < 500 Ko. Mesurer sur du matériel réel milieu de gamme, réseau throttlé « Slow 4G ».
|
||||
- La performance est ici **triplement alignée** : neuro-cognition (Doherty), respect du coût data du public, et souveraineté (moins de dépendances, moins de CDN tiers — servir polices et assets depuis sa propre infrastructure, jamais depuis Google Fonts).
|
||||
|
||||
---
|
||||
|
||||
## 5. Stack front-end web : Svelte + CSS
|
||||
|
||||
### 5.1 Svelte / SvelteKit : validation du choix
|
||||
|
||||
**Svelte 5 (licence MIT)** est probablement le meilleur alignement framework/doctrine OKI disponible :
|
||||
|
||||
- **Compilateur, pas runtime** : Svelte compile les composants en JavaScript impératif minimal — pas de Virtual DOM embarqué. Résultat : bundles drastiquement plus légers que React/Vue, donc directement au service du budget 3G (§4.4).
|
||||
- **Réactivité explicite par runes** (`$state`, `$derived`, `$effect` en Svelte 5) : le modèle mental est lisible et inspectable — compatible « comprendre avant d'automatiser ». Le code compilé est du JavaScript qu'on peut lire.
|
||||
- **CSS scopé natif** : chaque composant embarque son `<style>`, scopé automatiquement à la compilation, avec élimination du CSS mort. C'est un argument structurant pour la stratégie CSS (§5.2) : **Svelte réduit fortement le besoin d'un framework CSS**, car le problème historique que les frameworks résolvent (collisions de noms, CSS global ingérable) est résolu par le compilateur.
|
||||
- **SvelteKit 2 (MIT)** : SSR par défaut, amélioration progressive native (les formulaires fonctionnent sans JS via les form actions), routing par fichiers, adaptateurs de déploiement dont **adapter-node** (auto-hébergement simple sur l'infra OKI, derrière Nginx/Caddy) et adapter-static (sites purement statiques). Aucune dépendance à un cloud propriétaire.
|
||||
- **Écosystème utile et libre** : Bits UI / Melt UI (MIT, composants *headless* : comportement + accessibilité ARIA sans styles imposés — on garde la main sur 100 % du visuel), Paraglide/inlang pour l'i18n FR/créole (compilé, léger), Superforms pour les formulaires.
|
||||
|
||||
**Verdict** : Svelte 5 + SvelteKit 2 pour tout le web OKI (sites éditoriaux, fronts custom, PWA). Pour les plateformes fédérées déjà dotées de fronts (Mastodon, PeerTube), la doctrine s'applique via thèmes/CSS custom et, à terme, fronts alternatifs SvelteKit consommant leurs API.
|
||||
|
||||
### 5.2 CSS : analyse des options et recommandation
|
||||
|
||||
#### Le socle : la plateforme CSS de 2026 a rendu les frameworks largement optionnels
|
||||
|
||||
Le CSS natif dispose aujourd'hui, avec support universel des navigateurs à jour, de tout ce qui justifiait historiquement un framework :
|
||||
|
||||
| Capacité native | Usage |
|
||||
|---|---|
|
||||
| Custom properties (`--token`) | Design tokens, thématisation, mode sombre |
|
||||
| `oklch()` | Couleurs perceptuellement uniformes — construire une palette à contraste WCAG maîtrisé (méthode modus-vivendi) |
|
||||
| `light-dark()` + `color-scheme` | Mode sombre déclaratif sans duplication |
|
||||
| `clamp()` | Typographie et espacements fluides sans media queries |
|
||||
| Container queries (`@container`) | Composants responsives selon leur conteneur, pas selon l'écran — la bonne unité de responsive |
|
||||
| `:has()` | Sélection parent, états de formulaires sans JS |
|
||||
| Nesting natif | Lisibilité sans préprocesseur — Sass devient inutile |
|
||||
| `@layer` (cascade layers) | Architecture explicite de la cascade : reset < tokens < base < composants < utilitaires |
|
||||
| Grid + Subgrid | Toutes les mises en page, alignements imbriqués |
|
||||
| `prefers-*` | Respect des préférences utilisateur (§4.3) |
|
||||
|
||||
#### Options évaluées (filtre licence + ratio ego/compétence)
|
||||
|
||||
1. **CSS à la main sur design tokens** — *recommandation principale*.
|
||||
Coût initial : rédiger son système (tokens, reset, base, composants). Bénéfices : zéro dépendance, zéro build CSS, poids minimal, compréhension totale, thématisation triviale (une feuille de tokens par identité visuelle — OKI, Maskarad, DJANGOKAM, NYYOKA — sur la même base). C'est l'application directe de brique-par-brique, et le CSS scopé de Svelte en supprime le risque principal (l'entropie du CSS global).
|
||||
|
||||
2. **Open Props (MIT)** — *complément acceptable*.
|
||||
Pas un framework : une bibliothèque de **custom properties** prêtes (échelles d'espacement, tailles, ombres, easings, palettes). S'importe partiellement, se lit intégralement, ne génère aucune classe. Bon accélérateur pour construire ses tokens sans partir du néant, en gardant la maîtrise. Ratio ego/compétence excellent.
|
||||
|
||||
3. **Pico CSS (MIT)** — *pour les outils internes et prototypes*.
|
||||
« Classless » : style le HTML sémantique nu. Parfait pour les runbooks, interfaces d'admin, pages de statut — zéro effort, HTML propre obligatoire (vertu pédagogique). Pas pour les produits publics.
|
||||
|
||||
4. **Tailwind CSS 4 (MIT)** — *écarté pour OKI, en connaissance de cause*.
|
||||
Techniquement solide (moteur v4 rapide, tokens en CSS variables) et licence propre. Mais : (a) redondant avec le CSS scopé de Svelte ; (b) le HTML devient le lieu du style (classes utilitaires accumulées), ce qui disperse le système de design dans les templates au lieu de le centraliser dans des tokens ; (c) dépendance à un outil de build et à ses conventions — c'est le « Doom Emacs du CSS » : excellent en surface, il court-circuite la compréhension de la cascade. Verdict analogue à YunoHost : légitime ailleurs, contraire à la méthode ici.
|
||||
|
||||
5. **UnoCSS (MIT)** — même famille conceptuelle que Tailwind, mêmes conclusions.
|
||||
|
||||
6. **Bibliothèques de composants stylés** (Skeleton, DaisyUI, shadcn-svelte…) — écartées pour les produits publics : l'identité visuelle OKI/Maskarad ne doit ressembler à aucun template. Les **headless** (Bits UI / Melt UI, MIT) sont en revanche recommandés : ils n'apportent que le comportement et l'accessibilité (menus, dialogues, onglets conformes ARIA), le style reste 100 % maison.
|
||||
|
||||
#### Architecture CSS recommandée
|
||||
|
||||
```
|
||||
src/styles/
|
||||
├── tokens.css /* custom properties : couleurs oklch, échelle typo (clamp),
|
||||
espacements, rayons, ombres, durées, easings */
|
||||
├── reset.css /* reset moderne minimal (~30 lignes) */
|
||||
├── base.css /* HTML sémantique : typo, liens, formulaires, focus visible */
|
||||
├── layouts.css /* primitives de mise en page : .stack, .cluster, .sidebar,
|
||||
.center (approche "Every Layout" : compositions, pas pages) */
|
||||
└── themes/
|
||||
├── oki.css /* redéfinition des tokens uniquement */
|
||||
└── maskarad.css
|
||||
```
|
||||
|
||||
```css
|
||||
/* app.css — ordre de cascade explicite et auditable */
|
||||
@layer reset, tokens, base, layouts, components, utilities;
|
||||
|
||||
@import './styles/reset.css' layer(reset);
|
||||
@import './styles/tokens.css' layer(tokens);
|
||||
@import './styles/base.css' layer(base);
|
||||
@import './styles/layouts.css' layer(layouts);
|
||||
```
|
||||
|
||||
```css
|
||||
/* tokens.css — extrait illustratif */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Couleur : oklch = luminance perceptuelle maîtrisée => contraste WCAG calculable */
|
||||
--ink: light-dark(oklch(22% 0.02 260), oklch(90% 0.02 260));
|
||||
--surface: light-dark(oklch(98% 0.005 90), oklch(18% 0.01 260));
|
||||
--accent: oklch(60% 0.15 35);
|
||||
|
||||
/* Typo fluide : de 16px (mobile) à 18px (desktop) sans media query */
|
||||
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
|
||||
--measure: 65ch; /* largeur de lecture, §2.2 */
|
||||
|
||||
/* Échelle d'espacement modulaire */
|
||||
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 1rem;
|
||||
--space-4: 1.5rem; --space-5: 2.5rem;
|
||||
|
||||
--tap-target: 48px; /* Fitts, §1.3 */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
```
|
||||
|
||||
Le style spécifique à chaque composant vit dans son `<style>` Svelte et **consomme les tokens** — jamais de valeur brute (couleur, taille) dans un composant.
|
||||
|
||||
### 5.3 Typographie
|
||||
|
||||
- Corps ≥ 16 px mobile (via `--text-1` fluide), interlignage 1.5-1.6, largeur 45-75 ch.
|
||||
- **2 fichiers de police maximum** (une famille texte + éventuellement une display), format WOFF2, `font-display: swap`, **auto-hébergées** (jamais Google Fonts : performance, RGPD, souveraineté). Candidates libres (SIL OFL) : Inter, Source Sans/Serif, Atkinson Hyperlegible (conçue pour la lisibilité — pertinente pour l'accessibilité), Fraunces (display).
|
||||
- Hiérarchie par une échelle modulaire (ratio 1.2-1.333) encodée en tokens.
|
||||
|
||||
---
|
||||
|
||||
## 6. Applications natives : Qt et alternatives libres
|
||||
|
||||
### 6.1 Qt : viable, à condition de maîtriser la licence
|
||||
|
||||
- **Triple licence : LGPLv3 / GPLv3 / commerciale.** L'essentiel des modules Qt 6 est disponible en **LGPLv3** : utilisation libre y compris pour des applications sous licence permissive, **à condition de lier dynamiquement** Qt (l'utilisateur doit pouvoir remplacer la bibliothèque) et de ne pas modifier Qt sans republier ces modifications. Quelques modules périphériques sont GPL-only ou commerciaux — à vérifier module par module avant usage.
|
||||
- **Python** : attention au piège classique — **PySide6 (officiel, LGPLv3)** vs PyQt6 (Riverbank, **GPLv3 ou commercial**). Pour OKI : PySide6 exclusivement, sauf si l'application est elle-même GPL de toute façon.
|
||||
- **QML/Qt Quick** : déclaratif, adapté aux UI fluides tactiles ; **Qt Widgets** : classique, dense, adapté aux outils desktop de production. Les principes des §1-2 s'appliquent intégralement (Fitts, Hick, Doherty, thème sombre système, cibles tactiles si écran tactile).
|
||||
- Verdict : Qt (C++/QML ou PySide6) est légitime pour les **outils lourds de production** (pipeline créatif, traitement audio/vidéo local, intégration système profonde) où la performance native et la maturité des widgets comptent.
|
||||
|
||||
### 6.2 Tauri : la recommandation par défaut pour les apps OKI
|
||||
|
||||
**Tauri (MIT / Apache-2.0)** : coquille native légère (Rust) autour de la webview du système, embarquant le front web.
|
||||
|
||||
- **Réutilisation directe des composants Svelte** : un seul système de design, un seul savoir-faire, web + desktop + mobile (Tauri 2 couvre Android/iOS). C'est l'argument décisif : la doctrine UI de ce document s'implémente **une fois**.
|
||||
- Binaires de quelques Mo (contre >100 Mo pour Electron), pas de Chromium embarqué, backend Rust auditable — cohérent avec la trajectoire Rust déjà présente dans la stack (Kyber, Plakar).
|
||||
- Limite honnête : dépend de la webview système (WebKitGTK sous Linux) — tester les rendus ; et une app Tauri reste une app web dans ses sensations. Pour un outil exigeant l'intégration native profonde ou le temps réel dur, Qt reprend l'avantage.
|
||||
|
||||
### 6.3 Autres options, pour mémoire
|
||||
|
||||
- **GTK4 + libadwaita (LGPL)** : excellent citoyen Linux/GNOME, moins bon multiplateforme.
|
||||
- **Slint** : élégant (Rust/C++), mais licence GPLv3 *ou* « Royalty-free » propriétaire-compatible *ou* commerciale — le modèle est acceptable en GPL pur, à examiner de près sinon.
|
||||
- **Iced, egui (MIT/Apache)** : Rust natif, jeunes ; egui excellent pour outils internes rapides.
|
||||
- **Flutter (BSD)** : licence propre mais gouvernance Google et poids du runtime — ratio souveraineté défavorable.
|
||||
- **Kirigami (LGPL, KDE)** : pertinent si ancrage écosystème KDE.
|
||||
|
||||
### 6.4 Arbre de décision
|
||||
|
||||
```
|
||||
L'app est-elle essentiellement une interface sur des services OKI (web/API) ?
|
||||
├── OUI → Tauri + Svelte (un seul design system, MIT/Apache)
|
||||
└── NON → A-t-elle besoin d'intégration native profonde / temps réel / widgets denses ?
|
||||
├── OUI → Qt 6 en LGPLv3, liaison dynamique (Python : PySide6, jamais PyQt)
|
||||
└── OUI, et cible Linux uniquement → GTK4/libadwaita envisageable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Checklist d'audit OKI (à passer sur chaque écran / release)
|
||||
|
||||
### Cognition
|
||||
- [ ] ≤ 4 éléments à tenir en tête sur cet écran (sinon : redécouper)
|
||||
- [ ] ≤ 5 entrées de navigation ; une seule action primaire visuellement distincte
|
||||
- [ ] L'écran est compréhensible sans lecture (reconnaissance, pas lecture)
|
||||
- [ ] Conventions respectées ; toute déviation est délibérée et documentée
|
||||
|
||||
### Mobile
|
||||
- [ ] Actions primaires en zone du pouce (tiers inférieur)
|
||||
- [ ] Cibles ≥ 44-48 px, espacement ≥ 8 px, corps ≥ 16 px
|
||||
- [ ] Valeur livrée en < 5 s ; tâche reprennable après interruption
|
||||
- [ ] Utilisable hors-ligne (au moins en lecture de l'existant)
|
||||
|
||||
### Desktop
|
||||
- [ ] Lecture contrainte à 45-75 ch ; densité ajustable si vue liste
|
||||
- [ ] Navigation clavier complète ; raccourcis documentés (touche ?)
|
||||
- [ ] Aucune information exclusive au survol
|
||||
|
||||
### Performance
|
||||
- [ ] Premier rendu < 3 s en 3G throttlée sur matériel milieu de gamme
|
||||
- [ ] Interactions < 400 ms perçues ; feedback si > 1 s
|
||||
- [ ] JS initial < 100 Ko compressé ; page < 500 Ko ; polices auto-hébergées ≤ 2 fichiers
|
||||
- [ ] Fonctionne sans JavaScript (contenu + formulaires de base)
|
||||
|
||||
### Accessibilité
|
||||
- [ ] Contraste AA partout, AAA visé pour le corps de texte (palette oklch calculée)
|
||||
- [ ] Information jamais portée par la couleur seule
|
||||
- [ ] `prefers-color-scheme` / `reduced-motion` / `contrast` respectés ; tout en rem
|
||||
- [ ] HTML sémantique, focus visible, ordre de tabulation, test lecteur d'écran
|
||||
|
||||
### Éthique attentionnelle
|
||||
- [ ] Aucune récompense variable artificielle ; pas de scroll infini sans marqueur de fin
|
||||
- [ ] Notifications : actionnables, groupées, émises par des humains, désactivables finement
|
||||
- [ ] Aucun dark pattern (audit : désinscription, exports, valeurs par défaut)
|
||||
|
||||
### Territoire
|
||||
- [ ] FR/créole présent ; contenus d'exemple ancrés Caraïbe
|
||||
- [ ] Identité visuelle du territoire dès le premier écran (test des 50 ms)
|
||||
|
||||
---
|
||||
|
||||
## 8. Application aux projets en cours
|
||||
|
||||
### Fronts fédérés (Mastodon, PeerTube)
|
||||
Court terme : thèmes CSS custom appliquant les tokens OKI (les deux plateformes le permettent), audit checklist §7 des parcours d'inscription, instance d'entrée par défaut documentée. Moyen terme : fronts alternatifs SvelteKit sur leurs API pour maîtriser onboarding et éthique attentionnelle de bout en bout.
|
||||
|
||||
### Maskarad (Ren'Py)
|
||||
Ren'Py a son propre système de screens (screen language) : la doctrine s'applique par transposition — cibles tactiles 48 px sur mobile/tablette, zones de pouce pour l'avancée du texte (tap zones latérales en paysage tablette), typographie ≥ 16 px avec réglage de taille, contrastes AA sur toutes les boîtes de dialogue (y compris sur fonds illustrés : bandeau semi-opaque calculé), sauvegarde automatique agressive (reprennabilité, sessions courtes), `reduced-motion` transposé en option « réduire les animations », et bilinguisme FR/créole natif via le système de traduction Ren'Py. Les tokens de couleur Maskarad (palette dérivée des tests V1/V2/V6) doivent être définis une fois en variables de gui.rpy — même discipline que les tokens CSS.
|
||||
|
||||
### Chantier « Niméwik lib »
|
||||
Ce document constitue la brique « doctrine d'interface » du workstream : il fait le lien entre souveraineté technique (stack libre, auto-hébergement, performance frugale) et souveraineté cognitive (éthique attentionnelle, langue, accessibilité).
|
||||
|
||||
---
|
||||
|
||||
## 9. Références
|
||||
|
||||
**Neuro-cognition et lois de l'interaction**
|
||||
- Cowan, N. (2001). *The magical number 4 in short-term memory*. Behavioral and Brain Sciences.
|
||||
- Miller, G. A. (1956). *The magical number seven, plus or minus two*.
|
||||
- Sweller, J. (1988). *Cognitive load during problem solving*. Cognitive Science.
|
||||
- Kahneman, D. (2011). *Thinking, Fast and Slow* / *Système 1, Système 2*.
|
||||
- Fitts, P. M. (1954). *The information capacity of the human motor system*.
|
||||
- Hick, W. E. (1952). *On the rate of gain of information*.
|
||||
- Doherty, W. J. & Thadani, A. J. (1982). *The economic value of rapid response time*. IBM.
|
||||
- Lindgaard, G. et al. (2006). *Attention web designers: You have 50 milliseconds*. Behaviour & IT.
|
||||
- Nielsen Norman Group : F-pattern, loi de Jakob, heuristiques d'utilisabilité (nngroup.com).
|
||||
|
||||
**Attention et éthique**
|
||||
- Case, A. (2015). *Calm Technology*. O'Reilly.
|
||||
- Weiser, M. & Brown, J. S. (1996). *The Coming Age of Calm Technology*.
|
||||
- Eyal, N. (2014). *Hooked* — à lire comme catalogue de ce qu'on refuse.
|
||||
- Center for Humane Technology (humanetech.com).
|
||||
|
||||
**Design et CSS**
|
||||
- WCAG 2.2 (w3.org/TR/WCAG22) ; WAI-ARIA Authoring Practices.
|
||||
- Material Design 3 (m3.material.io) et Apple Human Interface Guidelines — à lire comme conventions dominantes (loi de Jakob), pas comme doctrine.
|
||||
- *Every Layout* (every-layout.dev) — primitives de composition CSS, très aligné brique-par-brique.
|
||||
- Open Props (open-props.style) ; Pico CSS (picocss.com).
|
||||
- Protesilaos Stavrou — modus-themes : méthodologie contraste-d'abord (protesilaos.com).
|
||||
- web.dev (performance, PWA, Core Web Vitals).
|
||||
|
||||
**Stack**
|
||||
- Svelte 5 / SvelteKit 2 (svelte.dev, MIT) ; Bits UI / Melt UI (MIT) ; Paraglide-inlang.
|
||||
- Tauri 2 (tauri.app, MIT/Apache-2.0).
|
||||
- Qt 6 licensing (qt.io/licensing) ; PySide6 (LGPLv3) — doc.qt.io/qtforpython.
|
||||
- Ren'Py screen language et système de traduction (renpy.org).
|
||||
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "atlas-fediverse",
|
||||
"version": "4.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && node scripts/postbuild-csp.mjs",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"private": "true",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/three": "^0.185.1",
|
||||
"svelte": "^5.56.6",
|
||||
"svelte-check": "^4.7.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Génère les fichiers de configuration CSP du déploiement statique :
|
||||
- build/nginx-csp.conf → snippet à inclure dans la config nginx de l'app
|
||||
YunoHost (cible de production : https://syel.o-k-i.net).
|
||||
- build/.htaccess → équivalent Apache (secours, ignoré par nginx).
|
||||
|
||||
Pourquoi : SvelteKit démarre l'hydratation via un script inline dans le
|
||||
HTML. Une CSP stricte sans « unsafe-inline » (ex. script-src 'self')
|
||||
bloque ce bootstrap → site visible mais non interactif. Les empreintes
|
||||
SHA-256 des scripts inline l'autorisent précisément, sans unsafe-inline.
|
||||
Régénéré à chaque build (les empreintes changent avec les chunks). */
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const buildDir = fileURLToPath(new URL('../build/', import.meta.url));
|
||||
|
||||
function* walkHtml(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) yield* walkHtml(full);
|
||||
else if (entry.endsWith('.html')) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const hashes = new Set();
|
||||
|
||||
for (const file of walkHtml(buildDir)) {
|
||||
const html = readFileSync(file, 'utf8');
|
||||
// Scripts inline uniquement (pas d'attribut src) — le navigateur empreinte
|
||||
// le contenu texte exact du bloc.
|
||||
for (const match of html.matchAll(/<script(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/g)) {
|
||||
const content = match[1];
|
||||
if (!content.trim()) continue;
|
||||
const digest = createHash('sha256').update(content, 'utf8').digest('base64');
|
||||
hashes.add(`'sha256-${digest}'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hashes.size === 0) {
|
||||
console.warn('postbuild-csp: aucun script inline trouvé — vérifier le build');
|
||||
}
|
||||
|
||||
// Politique sobre pour un site statique autohébergé, + empreintes du bootstrap.
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' ${[...hashes].sort().join(' ')}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self'",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"worker-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'"
|
||||
].join('; ');
|
||||
|
||||
const nginx = `# Généré par scripts/postbuild-csp.mjs — ne pas éditer à la main.
|
||||
# À inclure dans la config nginx de l'app YunoHost, dans le bloc location
|
||||
# servant les fichiers statiques, ex. :
|
||||
# location / {
|
||||
# alias /var/www/<app>/;
|
||||
# include /var/www/<app>/nginx-csp.conf;
|
||||
# try_files $uri $uri/ =404;
|
||||
# }
|
||||
# Note nginx : un add_header au niveau le plus précis REMPLACE les add_header
|
||||
# hérités des niveaux supérieurs — une éventuelle CSP globale est neutralisée.
|
||||
# Régénéré à chaque build : le ré-uploader avec le reste du contenu de build/.
|
||||
add_header Content-Security-Policy "${csp}" always;
|
||||
`;
|
||||
|
||||
const htaccess = `# Généré par scripts/postbuild-csp.mjs — ne pas éditer à la main.
|
||||
# Équivalent Apache du snippet nginx (secours uniquement — nginx l'ignore).
|
||||
<IfModule mod_headers.c>
|
||||
Header unset Content-Security-Policy
|
||||
Header always unset Content-Security-Policy
|
||||
Header set Content-Security-Policy "${csp}"
|
||||
</IfModule>
|
||||
`;
|
||||
|
||||
writeFileSync(join(buildDir, 'nginx-csp.conf'), nginx);
|
||||
writeFileSync(join(buildDir, '.htaccess'), htaccess);
|
||||
console.log(`postbuild-csp: nginx-csp.conf + .htaccess écrits (${hashes.size} empreinte(s))`);
|
||||
@@ -0,0 +1,8 @@
|
||||
/* app.css — point d'entrée des styles, ordre de cascade explicite */
|
||||
@layer reset, tokens, base, layouts, components, utilities;
|
||||
|
||||
@import './styles/reset.css' layer(reset);
|
||||
@import './styles/tokens.css' layer(tokens);
|
||||
@import './styles/themes/default.css' layer(tokens);
|
||||
@import './styles/base.css' layer(base);
|
||||
@import './styles/layouts.css' layer(layouts);
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Le Grand Atlas du Fédiverse — 2026</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé."
|
||||
/>
|
||||
<meta name="theme-color" content="#001219" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/icons/logo.svg" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/space-grotesk-var.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/inter-var.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<style>
|
||||
/* 2 fichiers WOFF2 variables, auto-hébergés, font-display: swap.
|
||||
Dans app.html pour bénéficier de %sveltekit.assets% (base /fediverse). */
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
src: url('%sveltekit.assets%/fonts/space-grotesk-var.woff2') format('woff2');
|
||||
font-weight: 300 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('%sveltekit.assets%/fonts/inter-var.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
<div
|
||||
id="boot-warning"
|
||||
hidden
|
||||
style="position: fixed; inset-inline: 1rem; inset-block-end: 1rem; z-index: 9999; padding: 1rem 1.25rem; border: 2px solid #f72585; border-radius: 1rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; font-size: 0.9375rem; line-height: 1.5; box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);"
|
||||
>
|
||||
<strong>L'application n'a pas pu démarrer.</strong>
|
||||
Si vous lisez ce fichier en local (<code>file://</code>), servez le dossier
|
||||
<code>build/</code> via HTTP (par exemple <code>npx serve build</code>) : les navigateurs
|
||||
bloquent les modules JavaScript sur <code>file://</code>. Sinon, videz les données du site
|
||||
dans votre navigateur puis rechargez la page.
|
||||
</div>
|
||||
<noscript>
|
||||
<div
|
||||
style="padding: 1rem 1.25rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; text-align: center;"
|
||||
>
|
||||
JavaScript est désactivé : le contenu de l'Atlas reste lisible, mais la carte 3D et les
|
||||
fenêtres de détails sont indisponibles.
|
||||
</div>
|
||||
</noscript>
|
||||
<script src="%sveltekit.assets%/boot-check.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { getLocale, LOCALES, setLocale, t } from '$lib/i18n/i18n.svelte';
|
||||
import type { Locale } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
const projectLinks = [
|
||||
{ href: `${base}/#map`, labelKey: 'footer.about' },
|
||||
{ href: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', labelKey: 'footer.license' },
|
||||
{ href: 'https://github.com/', labelKey: 'footer.source' },
|
||||
{ href: `${base}/data/fediverse.json`, labelKey: 'footer.api' }
|
||||
] as const;
|
||||
|
||||
const resourceLinks = [
|
||||
{ href: 'https://activitypub.rocks/', label: 'ActivityPub.rocks' },
|
||||
{ href: 'https://fedidb.org/', label: 'FediDB' },
|
||||
{ href: 'https://jointhefediverse.net/', label: 'Join the Fediverse' },
|
||||
{ href: 'https://fediverse.info/', label: 'Fediverse.info' }
|
||||
];
|
||||
|
||||
const communityLinks = [
|
||||
{ href: 'https://joinmastodon.org/fr', label: 'Mastodon' },
|
||||
{ href: 'https://join-lemmy.org/', label: 'Lemmy' },
|
||||
{ href: 'https://joinpeertube.org/', label: 'PeerTube' },
|
||||
{ href: 'https://pixelfed.org/', label: 'PixelFed' }
|
||||
];
|
||||
|
||||
const downloads = [
|
||||
{ href: `${base}/data/fediverse.json`, label: 'JSON' },
|
||||
{ href: `${base}/data/fediverse.yaml`, label: 'YAML' },
|
||||
{ href: `${base}/infographie-fediverse-2026.svg`, label: 'Infographie SVG' },
|
||||
{ href: `${base}/infographie-fediverse-2026.pdf`, label: 'Infographie PDF' }
|
||||
];
|
||||
|
||||
const currentLocale = $derived(getLocale());
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<div class="center stack footer-stack">
|
||||
<div class="footer-grid">
|
||||
<div class="stack brand-col">
|
||||
<a href="{base}/" class="brand" aria-label={t('header.home')}>
|
||||
<img src="{base}/icons/logo.svg" alt="" width="40" height="40" />
|
||||
<span class="brand-name">Le Grand Atlas du Fédiverse</span>
|
||||
</a>
|
||||
<p class="muted">{t('footer.baseline')}</p>
|
||||
<p class="muted small">Version 4.0 · 2026 · {t('footer.madeIn')}</p>
|
||||
|
||||
<div class="locale-switcher" role="group" aria-label={t('footer.localeLabel')}>
|
||||
{#each LOCALES as locale (locale.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="locale-btn"
|
||||
aria-pressed={currentLocale === locale.id}
|
||||
onclick={() => setLocale(locale.id as Locale)}
|
||||
>
|
||||
{locale.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.project')}>
|
||||
<h3>{t('footer.project')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each projectLinks as link (link.href)}
|
||||
<li>
|
||||
<a href={link.href} {...link.href.startsWith('http') ? { rel: 'external' } : {}}>
|
||||
{t(link.labelKey)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.resources')}>
|
||||
<h3>{t('footer.resources')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each resourceLinks as link (link.href)}
|
||||
<li><a href={link.href} rel="external">{link.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.community')}>
|
||||
<h3>{t('footer.community')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each communityLinks as link (link.href)}
|
||||
<li><a href={link.href} rel="external">{link.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="downloads cluster" aria-label="Téléchargements">
|
||||
{#each downloads as dl (dl.href)}
|
||||
<a href={dl.href} class="download-chip" download>{dl.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bottom-bar stack">
|
||||
<p class="small muted">{t('footer.rights')}</p>
|
||||
<p class="small muted">{t('footer.credits')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
footer {
|
||||
background: var(--abyss);
|
||||
border-block-start: 1px solid var(--border);
|
||||
padding-block: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.footer-stack {
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-2);
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.brand-col {
|
||||
--stack-gap: var(--space-3);
|
||||
max-inline-size: 34rem;
|
||||
}
|
||||
|
||||
.link-col {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.link-col ul {
|
||||
--stack-gap: var(--space-1);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.link-col a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: 2rem;
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-col a:hover {
|
||||
color: var(--accent-2-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.locale-switcher {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
|
||||
.locale-btn {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink-soft);
|
||||
font-weight: 500;
|
||||
transition:
|
||||
color var(--duration-1) var(--ease-out),
|
||||
border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.locale-btn:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.locale-btn[aria-pressed='true'] {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.downloads {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.download-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.download-chip:hover {
|
||||
color: var(--accent-2-text);
|
||||
border-color: var(--accent-2-text);
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
--stack-gap: var(--space-1);
|
||||
border-block-start: 1px solid var(--border);
|
||||
padding-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { base } from '$app/paths';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { canInstall, promptInstall } from '$lib/pwa.svelte';
|
||||
|
||||
let scrolled = $state(false);
|
||||
let hidden = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuButton = $state<HTMLButtonElement | null>(null);
|
||||
let mobileNav = $state<HTMLElement | null>(null);
|
||||
|
||||
const anchors = [
|
||||
{ href: `${base}/#map`, key: 'nav.map' },
|
||||
{ href: `${base}/#protocols`, key: 'nav.protocols' },
|
||||
{ href: `${base}/#timeline`, key: 'nav.timeline' },
|
||||
{ href: `${base}/#catalog`, key: 'nav.catalog' }
|
||||
] as const;
|
||||
|
||||
// Slash final : évite la redirection 301 d'Apache (DirectorySlash)
|
||||
const routes = [
|
||||
{ href: `${base}/nostr/`, key: 'nav.nostr', accent: 'var(--accent-3)' },
|
||||
{ href: `${base}/simplex/`, key: 'nav.simplex', accent: 'var(--accent-4)' }
|
||||
] as const;
|
||||
|
||||
const pathname: string = $derived(page.url.pathname);
|
||||
|
||||
onMount(() => {
|
||||
let lastY = 0;
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY;
|
||||
scrolled = y > 80;
|
||||
// Auto-masquage : disparaît en défilant vers le bas (lecture),
|
||||
// réapparaît dès que l'utilisateur remonte. Jamais masqué en haut
|
||||
// de page ni quand le menu mobile est ouvert.
|
||||
if (y > 160 && y > lastY) {
|
||||
hidden = true;
|
||||
menuOpen = false;
|
||||
} else if (y < lastY || y <= 160) {
|
||||
hidden = false;
|
||||
}
|
||||
lastY = y;
|
||||
};
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
menuOpen = false;
|
||||
menuButton?.focus();
|
||||
}
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
// Ferme le menu mobile à chaque navigation
|
||||
$effect(() => {
|
||||
pathname;
|
||||
menuOpen = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
mobileNav?.querySelector<HTMLElement>('a, button')?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class:scrolled class:hidden>
|
||||
<div class="header-inner center">
|
||||
<a href="{base}/" class="brand" aria-label={t('header.home')}>
|
||||
<img src="{base}/icons/logo.svg" alt="" width="34" height="34" />
|
||||
<span class="wordmark">Atlas</span>
|
||||
</a>
|
||||
|
||||
<nav class="desktop-nav" aria-label={t('nav.main')}>
|
||||
<ul class="cluster" role="list">
|
||||
{#each anchors as link (link.href)}
|
||||
<li><a href={link.href} class="nav-link">{t(link.key)}</a></li>
|
||||
{/each}
|
||||
{#each routes as route (route.href)}
|
||||
<li>
|
||||
<a
|
||||
href={route.href}
|
||||
class="nav-link route-link"
|
||||
style:--link-accent={route.accent}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
>
|
||||
{ t(route.key)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="actions cluster">
|
||||
{#if canInstall()}
|
||||
<button type="button" class="install-btn" onclick={() => promptInstall()}>
|
||||
{t('header.install')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
bind:this={menuButton}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={menuOpen ? t('nav.closeMenu') : t('nav.openMenu')}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{#if menuOpen}
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
class="mobile-nav"
|
||||
aria-label={t('nav.main')}
|
||||
bind:this={mobileNav}
|
||||
>
|
||||
<ul class="stack" role="list">
|
||||
{#each anchors as link (link.href)}
|
||||
<li><a href={link.href} class="mobile-link">{t(link.key)}</a></li>
|
||||
{/each}
|
||||
{#each routes as route (route.href)}
|
||||
<li>
|
||||
<a
|
||||
href={route.href}
|
||||
class="mobile-link route-link"
|
||||
style:--link-accent={route.accent}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
>
|
||||
{t(route.key)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{#if canInstall()}
|
||||
<li>
|
||||
<button type="button" class="mobile-link install-mobile" onclick={() => promptInstall()}>
|
||||
{t('header.install')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
header {
|
||||
position: fixed;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 50;
|
||||
block-size: var(--header-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
transition:
|
||||
transform var(--duration-2) var(--ease-out),
|
||||
background-color var(--duration-2) var(--ease-out),
|
||||
border-color var(--duration-2) var(--ease-out);
|
||||
border-block-end: 1px solid transparent;
|
||||
}
|
||||
|
||||
header.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
header.scrolled {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-block-end-color: var(--glass-border);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 700;
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.route-link {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.route-link:hover,
|
||||
.route-link[aria-current='page'] {
|
||||
color: var(--link-accent);
|
||||
border-color: var(--link-accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-accent);
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
position: fixed;
|
||||
inset-block-start: var(--header-h);
|
||||
inset-inline: 0;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-block-end: 1px solid var(--glass-border);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-nav ul {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-1);
|
||||
font-weight: 500;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.mobile-link:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.install-mobile {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.desktop-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
/* HeroSection — plein viewport, shader océan en fond, titre + CTA. */
|
||||
import { stats } from '$lib/data';
|
||||
import HeroShader from './HeroShader.svelte';
|
||||
</script>
|
||||
|
||||
<section id="hero" aria-labelledby="hero-title">
|
||||
<HeroShader />
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<div class="content center stack">
|
||||
<h1 id="hero-title">
|
||||
Le réseau social,<br />
|
||||
<span class="gradient">sans les chaînes</span>
|
||||
</h1>
|
||||
|
||||
<p class="lede">
|
||||
Explorez le Fédiverse : une cartographie visuelle des
|
||||
<strong>{stats.totalSoftwares} logiciels</strong>
|
||||
qui redéfinissent la communication en ligne.
|
||||
</p>
|
||||
|
||||
<a href="#map" class="cta">
|
||||
Plonger dans la carte
|
||||
<svg
|
||||
class="chevron"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="scroll-indicator" aria-hidden="true">
|
||||
<span class="scroll-label">Scroll</span>
|
||||
<span class="scroll-line"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
/* Contexte sombre local : light-dark() bascule en mode sombre ici,
|
||||
quel que soit le thème de la page. */
|
||||
color-scheme: dark;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 100svh;
|
||||
overflow: hidden;
|
||||
background: var(--abyss);
|
||||
color: var(--on-accent);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
/* Voile de lisibilité : garantit le contraste du texte sur le shader */
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--abyss) 45%, transparent),
|
||||
color-mix(in oklab, var(--abyss) 25%, transparent) 45%,
|
||||
var(--abyss)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
--stack-gap: var(--space-4);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(var(--text-4), 4vw + 1.5rem, 4.5rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-inline-size: var(--measure);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: var(--text-small);
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-accent);
|
||||
transition: transform var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
animation: bounce 1.6s var(--ease-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
inset-block-end: var(--space-4);
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.scroll-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.scroll-line {
|
||||
inline-size: 1px;
|
||||
block-size: var(--space-5);
|
||||
background: linear-gradient(180deg, var(--ink-soft), transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chevron {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
/* HeroShader — océan WebGL (Three.js, chargé paresseusement côté client).
|
||||
Remplit son parent en absolu ; purement décoratif (aria-hidden). */
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/* Palette cosinus type Iñigo Quilez : a + b*cos(6.28318*(c*t+d)) */
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 palette(in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d) {
|
||||
return a + b * cos(6.28318 * (c * t + d));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = (vUv - 0.5) * 2.0;
|
||||
uv.x *= u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv0 = uv;
|
||||
vec3 finalColor = vec3(0.0);
|
||||
|
||||
for (float i = 0.0; i < 3.0; i++) {
|
||||
uv = fract(uv * 1.5) - 0.5;
|
||||
|
||||
float d = length(uv) * exp(-length(uv0));
|
||||
|
||||
vec3 col = palette(length(uv0) + i * 0.4 + u_time * 0.4,
|
||||
vec3(0.5, 0.5, 0.9),
|
||||
vec3(0.5, 0.5, 0.4),
|
||||
vec3(1.0, 1.0, 0.9),
|
||||
vec3(0.0, 0.1, 0.2));
|
||||
|
||||
d = sin(d * 8.0 + u_time) / 8.0;
|
||||
d = abs(d);
|
||||
d = pow(0.01 / d, 1.2);
|
||||
|
||||
finalColor += col * d;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
(async () => {
|
||||
// Import paresseux obligatoire : rien de Three au top-level (SSR).
|
||||
const THREE = await import('three');
|
||||
if (disposed || !container) return;
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const width = container.offsetWidth || window.innerWidth;
|
||||
const height = container.offsetHeight || window.innerHeight;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 2000);
|
||||
camera.position.z = 1;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.domElement.style.width = '100%';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const uniforms = {
|
||||
u_time: { value: 0.0 },
|
||||
u_resolution: { value: new THREE.Vector2(width, height) }
|
||||
};
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(2, 2);
|
||||
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
uniforms.u_time.value += 0.008;
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (reduceMotion) {
|
||||
// Mouvement réduit : une seule frame statique (u_time fixe), pas de boucle.
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
// Frugalité : la boucle ne tourne que si le canvas est visible à l'écran
|
||||
// ET si l'onglet est actif.
|
||||
let inView = false;
|
||||
let pageVisible = !document.hidden;
|
||||
|
||||
const stop = () => {
|
||||
if (raf !== 0) {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
const sync = () => {
|
||||
if (inView && pageVisible) {
|
||||
if (raf === 0) raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
inView = entries[0]?.isIntersecting ?? false;
|
||||
sync();
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
observer.observe(container);
|
||||
|
||||
const onVisibility = () => {
|
||||
pageVisible = !document.hidden;
|
||||
sync();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = container.offsetHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
uniforms.u_resolution.value.set(w, h);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
cleanup = () => {
|
||||
stop();
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Chemin reduced-motion : pas d'observateurs d'animation, juste le resize.
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = container.offsetHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
uniforms.u_resolution.value.set(w, h);
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
cleanup = () => {
|
||||
resizeObserver.disconnect();
|
||||
renderer.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shader" bind:this={container} aria-hidden="true"></div>
|
||||
|
||||
<style>
|
||||
.shader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,797 @@
|
||||
<script lang="ts">
|
||||
/* Graphe 3D de l'écosystème — Three.js + d3-force.
|
||||
Les deux libs sont chargées paresseusement (await import dans onMount) :
|
||||
aucun octet de Three/D3 dans le bundle initial. Topologie déterministe
|
||||
issue de networkData (network.json pré-calculé, pas de Math.random de liens). */
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { categories, networkData, softwares } from '$lib/data';
|
||||
import type { NetworkNode, Software } from '$lib/data';
|
||||
import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from 'd3-force';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
type SimNode = NetworkNode & SimulationNodeDatum;
|
||||
type SimLink = SimulationLinkDatum<SimNode> & { type: 'strong' | 'weak' };
|
||||
|
||||
interface TooltipState {
|
||||
node: NetworkNode;
|
||||
software?: Software;
|
||||
x: number;
|
||||
y: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
let viewport = $state<HTMLDivElement | null>(null);
|
||||
// SSR / sans-JS : booting reste false → message de repli lisible (pas de
|
||||
// squelette éternel). onMount le passe à true avant de charger les chunks.
|
||||
let booting = $state(false);
|
||||
let loading = $state(true);
|
||||
let unavailable = $state(false);
|
||||
let tooltip = $state<TooltipState | null>(null);
|
||||
|
||||
let filters = $state<Record<string, boolean>>(
|
||||
Object.fromEntries(categories.map((c) => [c.id, true]))
|
||||
);
|
||||
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
|
||||
|
||||
// Assigné par onMount une fois les libs chargées.
|
||||
let rebuildGraph: (() => void) | null = null;
|
||||
|
||||
function toggleFilter(categoryId: string) {
|
||||
filters = { ...filters, [categoryId]: !filters[categoryId] };
|
||||
tooltip = null;
|
||||
rebuildGraph?.();
|
||||
}
|
||||
|
||||
function closeTooltip() {
|
||||
tooltip = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!viewport) return;
|
||||
booting = true;
|
||||
const container = viewport;
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let cancelled = false;
|
||||
let disposeGraph: (() => void) | null = null;
|
||||
let setPaused: ((paused: boolean) => void) | null = null;
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') tooltip = null;
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
|
||||
let ioPaused = false;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
ioPaused = !entries[0]?.isIntersecting;
|
||||
setPaused?.(ioPaused || document.hidden);
|
||||
},
|
||||
{ threshold: 0.05 }
|
||||
);
|
||||
const onVisibility = () => setPaused?.(document.hidden || ioPaused);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const THREE = await import('three');
|
||||
const d3 = await import('d3-force');
|
||||
if (cancelled) return;
|
||||
|
||||
// ————— Helpers couleurs : var(--…) → rgb utilisable par Three.js.
|
||||
// THREE.Color ne comprend ni oklch() ni light-dark() : conversion manuelle.
|
||||
const oklchToSrgb = (L: number, C: number, H: number): [number, number, number] => {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
const a = C * Math.cos(hRad);
|
||||
const b = C * Math.sin(hRad);
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
||||
const l = l_ ** 3;
|
||||
const m = m_ ** 3;
|
||||
const s = s_ ** 3;
|
||||
const to255 = (v: number): number => {
|
||||
const c = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
return Math.round(Math.min(Math.max(c, 0), 1) * 255);
|
||||
};
|
||||
return [
|
||||
to255(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
to255(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
to255(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
|
||||
];
|
||||
};
|
||||
|
||||
const toRgb = (color: string, fallback: string): string => {
|
||||
const oklchMatch = color.match(
|
||||
/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*[\d.]+%?)?\s*\)/
|
||||
);
|
||||
if (oklchMatch) {
|
||||
const lightness = parseFloat(oklchMatch[1]) / (oklchMatch[2] ? 100 : 1);
|
||||
const [r, g, b] = oklchToSrgb(
|
||||
lightness,
|
||||
parseFloat(oklchMatch[3]),
|
||||
parseFloat(oklchMatch[4])
|
||||
);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
if (/^(#|rgb)/.test(color)) return color;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const cssColor = (varName: string, fallback: string): string => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `var(${varName}, ${fallback})`;
|
||||
probe.style.display = 'none';
|
||||
container.appendChild(probe);
|
||||
const computed = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return toRgb(computed, fallback);
|
||||
};
|
||||
|
||||
const inkColor = cssColor('--ink', '#e8eef4');
|
||||
const lineColor = cssColor('--border-strong', '#8a97a8');
|
||||
|
||||
// ————— Scène persistante —————
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
container.offsetWidth / Math.max(container.offsetHeight, 1),
|
||||
0.1,
|
||||
2000
|
||||
);
|
||||
camera.position.z = 100;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(container.offsetWidth, container.offsetHeight);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
|
||||
const directional = new THREE.DirectionalLight(0xffffff, 1.4);
|
||||
directional.position.set(40, 60, 80);
|
||||
scene.add(directional);
|
||||
|
||||
const graphGroup = new THREE.Group();
|
||||
scene.add(graphGroup);
|
||||
|
||||
// ————— État du sous-graphe courant —————
|
||||
let instancedMesh: InstanceType<typeof THREE.InstancedMesh> | null = null;
|
||||
let simulation: Simulation<SimNode, undefined> | null = null;
|
||||
let currentNodes: SimNode[] = [];
|
||||
const labelSprites = new Map<string, InstanceType<typeof THREE.Sprite>>();
|
||||
const linkLines: InstanceType<typeof THREE.Line>[] = [];
|
||||
const nodePositions = new Map<string, InstanceType<typeof THREE.Vector3>>();
|
||||
|
||||
const teardownSubgraph = () => {
|
||||
simulation?.stop();
|
||||
simulation = null;
|
||||
graphGroup.traverse((obj) => {
|
||||
if (obj instanceof THREE.InstancedMesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as import('three').Material).dispose();
|
||||
} else if (obj instanceof THREE.Sprite) {
|
||||
obj.material.map?.dispose();
|
||||
obj.material.dispose();
|
||||
} else if (obj instanceof THREE.Line) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as import('three').Material).dispose();
|
||||
}
|
||||
});
|
||||
graphGroup.clear();
|
||||
labelSprites.clear();
|
||||
linkLines.length = 0;
|
||||
nodePositions.clear();
|
||||
instancedMesh = null;
|
||||
currentNodes = [];
|
||||
};
|
||||
|
||||
const applyPositions = (dummy: InstanceType<typeof THREE.Object3D>) => {
|
||||
const mesh = instancedMesh;
|
||||
if (!mesh) return;
|
||||
currentNodes.forEach((node, i) => {
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
const z = node.z ?? 0;
|
||||
const scale = (node.val || 5) * 0.4;
|
||||
|
||||
dummy.position.set(x, y, z);
|
||||
dummy.scale.set(scale, scale, scale);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(i, dummy.matrix);
|
||||
|
||||
labelSprites.get(node.id)?.position.set(x, y + scale + 2, z);
|
||||
nodePositions.set(node.id, new THREE.Vector3(x, y, z));
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
|
||||
for (const line of linkLines) {
|
||||
const src = nodePositions.get(line.userData.source as string);
|
||||
const tgt = nodePositions.get(line.userData.target as string);
|
||||
if (!src || !tgt) continue;
|
||||
const attr = line.geometry.getAttribute(
|
||||
'position'
|
||||
) as import('three').BufferAttribute;
|
||||
const arr = attr.array as Float32Array;
|
||||
arr[0] = src.x; arr[1] = src.y; arr[2] = src.z;
|
||||
arr[3] = tgt.x; arr[4] = tgt.y; arr[5] = tgt.z;
|
||||
attr.needsUpdate = true;
|
||||
}
|
||||
};
|
||||
|
||||
const dummy = new THREE.Object3D();
|
||||
|
||||
const buildSubgraph = () => {
|
||||
teardownSubgraph();
|
||||
tooltip = null;
|
||||
|
||||
const activeCats = new Set(
|
||||
Object.entries(filters)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
);
|
||||
|
||||
currentNodes = networkData.nodes
|
||||
.filter((n) => activeCats.has(n.category))
|
||||
.map((n) => ({
|
||||
...n,
|
||||
x: (Math.random() - 0.5) * 60,
|
||||
y: (Math.random() - 0.5) * 60,
|
||||
z: (Math.random() - 0.5) * 30
|
||||
}));
|
||||
|
||||
if (currentNodes.length === 0) {
|
||||
renderer.render(scene, camera);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeIds = new Set(currentNodes.map((n) => n.id));
|
||||
const links: SimLink[] = networkData.links
|
||||
.filter(
|
||||
(l) =>
|
||||
nodeIds.has(l.source as string) && nodeIds.has(l.target as string)
|
||||
)
|
||||
.map((l) => ({
|
||||
source: l.source as string,
|
||||
target: l.target as string,
|
||||
type: l.type
|
||||
}));
|
||||
|
||||
// Nœuds (instanciés)
|
||||
const geometry = new THREE.SphereGeometry(1, 16, 16);
|
||||
const material = new THREE.MeshPhysicalMaterial({
|
||||
color: 0xffffff,
|
||||
metalness: 0.1,
|
||||
roughness: 0.3,
|
||||
clearcoat: 1.0,
|
||||
clearcoatRoughness: 0.1
|
||||
});
|
||||
instancedMesh = new THREE.InstancedMesh(geometry, material, currentNodes.length);
|
||||
instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
currentNodes.forEach((node, i) => {
|
||||
instancedMesh!.setColorAt(i, new THREE.Color(node.color || '#888888'));
|
||||
});
|
||||
if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true;
|
||||
graphGroup.add(instancedMesh);
|
||||
|
||||
// Labels (un canvas par sprite — le partage de canvas entre
|
||||
// CanvasTexture afficherait le dernier label partout)
|
||||
for (const node of currentNodes) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.font = '500 24px "Space Grotesk", sans-serif';
|
||||
ctx.fillStyle = inkColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(node.id, 128, 32);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const spriteMaterial = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.85
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.scale.set(12, 3, 1);
|
||||
graphGroup.add(sprite);
|
||||
labelSprites.set(node.id, sprite);
|
||||
}
|
||||
|
||||
// Liens
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(lineColor),
|
||||
transparent: true,
|
||||
opacity: 0.3
|
||||
});
|
||||
for (const link of links) {
|
||||
const positions = new Float32Array(6);
|
||||
const lineGeometry = new THREE.BufferGeometry();
|
||||
lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial);
|
||||
line.userData = {
|
||||
source: link.source,
|
||||
target: link.target
|
||||
};
|
||||
graphGroup.add(line);
|
||||
linkLines.push(line);
|
||||
}
|
||||
|
||||
// Simulation (2D, z statique — parité avec la version React)
|
||||
simulation = d3
|
||||
.forceSimulation<SimNode>(currentNodes)
|
||||
.force(
|
||||
'link',
|
||||
d3
|
||||
.forceLink<SimNode, SimLink>(links)
|
||||
.id((d) => d.id)
|
||||
.distance((d) => (d.type === 'strong' ? 15 : 35))
|
||||
)
|
||||
.force('charge', d3.forceManyBody().strength(-100))
|
||||
.force('center', d3.forceCenter(0, 0))
|
||||
.force(
|
||||
'collide',
|
||||
d3
|
||||
.forceCollide<SimNode>()
|
||||
.radius((d) => (d.group === 1 ? 6 : 3) + 2)
|
||||
.iterations(2)
|
||||
)
|
||||
.force('x', d3.forceX(0).strength(0.04))
|
||||
.force('y', d3.forceY(0).strength(0.04))
|
||||
.alphaDecay(0.02);
|
||||
|
||||
if (reducedMotion) {
|
||||
// Convergence synchrone, une seule frame rendue.
|
||||
simulation.stop();
|
||||
for (let i = 0; i < 300; i++) simulation.tick();
|
||||
applyPositions(dummy);
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
simulation.on('tick', () => applyPositions(dummy));
|
||||
}
|
||||
};
|
||||
|
||||
// ————— Interactions —————
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
|
||||
const pick = (clientX: number, clientY: number): SimNode | null => {
|
||||
if (!instancedMesh) return null;
|
||||
const rect = container.getBoundingClientRect();
|
||||
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hits = raycaster.intersectObject(instancedMesh);
|
||||
const id = hits[0]?.instanceId;
|
||||
return id !== undefined ? (currentNodes[id] ?? null) : null;
|
||||
};
|
||||
|
||||
const showTooltip = (node: SimNode, clientX: number, clientY: number, pinned: boolean) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
tooltip = {
|
||||
node,
|
||||
software: softwares.find((s) => s.name === node.id),
|
||||
x: Math.min(clientX - rect.left + 20, rect.width - 280),
|
||||
y: Math.min(clientY - rect.top + 20, rect.height - 220),
|
||||
pinned
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'mouse' || tooltip?.pinned) return;
|
||||
const node = pick(event.clientX, event.clientY);
|
||||
if (node) {
|
||||
showTooltip(node, event.clientX, event.clientY, false);
|
||||
container.style.cursor = 'pointer';
|
||||
} else {
|
||||
tooltip = null;
|
||||
container.style.cursor = 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const node = pick(event.clientX, event.clientY);
|
||||
if (node) {
|
||||
showTooltip(node, event.clientX, event.clientY, true);
|
||||
} else {
|
||||
tooltip = null;
|
||||
}
|
||||
};
|
||||
|
||||
// ————— Boucle de rendu & pause —————
|
||||
let rafId = 0;
|
||||
let paused = false;
|
||||
|
||||
const animate = () => {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
setPaused = (next: boolean) => {
|
||||
if (reducedMotion) return;
|
||||
if (next === paused) return;
|
||||
paused = next;
|
||||
if (paused) {
|
||||
cancelAnimationFrame(rafId);
|
||||
} else {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = Math.max(container.offsetHeight, 1);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (reducedMotion || paused) renderer.render(scene, camera);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
container.addEventListener('pointermove', onPointerMove);
|
||||
container.addEventListener('click', onClick);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
buildSubgraph();
|
||||
rebuildGraph = buildSubgraph;
|
||||
loading = false;
|
||||
|
||||
if (!reducedMotion) animate();
|
||||
|
||||
disposeGraph = () => {
|
||||
setPaused = null;
|
||||
rebuildGraph = null;
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
container.removeEventListener('pointermove', onPointerMove);
|
||||
container.removeEventListener('click', onClick);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
teardownSubgraph();
|
||||
renderer.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
unavailable = true;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
io.observe(container);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
io.disconnect();
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
disposeGraph?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="map" aria-labelledby="map-title">
|
||||
<div class="center section-head stack">
|
||||
<p class="kicker">Cartographie</p>
|
||||
<h2 id="map-title">L'Écosystème</h2>
|
||||
<p class="subtitle">
|
||||
{activeCount} catégories actives · Survolez ou touchez un nœud pour plus d'infos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="graph-wrap center">
|
||||
<div
|
||||
class="viewport"
|
||||
bind:this={viewport}
|
||||
role="img"
|
||||
aria-label="Graphe 3D des 105 logiciels du Fédiverse, colorés par catégorie. Le détail de chaque logiciel est aussi disponible dans le catalogue."
|
||||
>
|
||||
{#if !booting}
|
||||
<div class="fallback stack">
|
||||
<p>La carte 3D interactive nécessite JavaScript.</p>
|
||||
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="skeleton" role="status">
|
||||
<span>Chargement de la carte…</span>
|
||||
</div>
|
||||
{:else if unavailable}
|
||||
<div class="fallback stack">
|
||||
<p>La carte 3D n'est pas disponible dans ce navigateur.</p>
|
||||
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<aside class="filters" aria-label="Filtres par catégorie">
|
||||
<h3>Filtres</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each categories as cat (cat.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
class:inactive={!filters[cat.id]}
|
||||
aria-pressed={filters[cat.id]}
|
||||
style:--cat-color={cat.color}
|
||||
onclick={() => toggleFilter(cat.id)}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="cat-name">{cat.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{#if tooltip}
|
||||
<div
|
||||
class="tooltip"
|
||||
style:left="{tooltip.x}px"
|
||||
style:top="{tooltip.y}px"
|
||||
role={tooltip.pinned ? 'dialog' : 'status'}
|
||||
aria-label={tooltip.pinned ? `Détails de ${tooltip.node.id}` : undefined}
|
||||
>
|
||||
<div class="tooltip-head">
|
||||
<span class="dot glow" style:--cat-color={tooltip.node.color} aria-hidden="true"></span>
|
||||
<h4>{tooltip.node.id}</h4>
|
||||
{#if tooltip.pinned}
|
||||
<button type="button" class="tooltip-close" onclick={closeTooltip} aria-label={t('common.close')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="tooltip-desc">{tooltip.node.description}</p>
|
||||
<dl class="tooltip-meta mono">
|
||||
<div><dt>Licence</dt><dd>{tooltip.node.licence}</dd></div>
|
||||
{#if tooltip.software}
|
||||
<div><dt>Langage</dt><dd>{tooltip.software.language}</dd></div>
|
||||
{/if}
|
||||
<div><dt>État</dt><dd>{tooltip.node.etat}</dd></div>
|
||||
<div><dt>ActivityPub</dt><dd>{tooltip.node.compatibiliteAP}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--surface);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
--stack-gap: var(--space-2);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.graph-wrap {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
block-size: clamp(30rem, 85vh, 56rem);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fallback-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ————— Panneau de filtres ————— */
|
||||
.filters {
|
||||
margin-block-start: var(--space-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.filters h3 {
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
--stack-gap: var(--space-1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-2);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.filter-btn .dot {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cat-color);
|
||||
box-shadow: 0 0 0.5rem var(--cat-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .dot {
|
||||
background: var(--muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .cat-name {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
@container (min-width: 56rem) {
|
||||
.filters {
|
||||
position: absolute;
|
||||
inset-block-start: var(--space-3);
|
||||
inset-inline-end: var(--space-3);
|
||||
margin-block-start: 0;
|
||||
max-inline-size: 15rem;
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— Tooltip / panneau détail ————— */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
max-inline-size: 17rem;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
box-shadow: var(--shadow-2);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.tooltip-head h4 {
|
||||
font-size: var(--text-1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dot.glow {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cat-color);
|
||||
box-shadow: 0 0 0.5rem var(--cat-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tooltip-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tooltip-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.tooltip-meta {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.tooltip-meta div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tooltip-meta dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tooltip-meta dd {
|
||||
color: var(--ink);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let online = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
online = navigator.onLine;
|
||||
const handleOnline = () => (online = true);
|
||||
const handleOffline = () => (online = false);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !online}
|
||||
<div class="offline-pill" role="status">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('offline.indicator')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.offline-pill {
|
||||
position: fixed;
|
||||
inset-block-end: var(--space-3);
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-small);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--status-maintenance);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
/* ProtocolSection — grille des 12 protocoles sur fond « data stream ». */
|
||||
import { protocols, type Protocol } from '$lib/data';
|
||||
import ProtocolStream from './ProtocolStream.svelte';
|
||||
|
||||
const TYPES: Record<Protocol['type'], { label: string; color: string }> = {
|
||||
fediverse: { label: 'Protocole du Fédiverse', color: 'var(--accent-4-text)' },
|
||||
connexe: { label: 'Protocole connexe', color: 'var(--status-maintenance)' },
|
||||
voisin: { label: 'Écosystème voisin', color: 'var(--accent-text)' }
|
||||
};
|
||||
|
||||
const TYPE_ORDER: Protocol['type'][] = ['fediverse', 'connexe', 'voisin'];
|
||||
|
||||
/* Couleur doublée par le texte du statut (jamais la couleur seule) */
|
||||
const STATUS_COLORS: Record<Protocol['statut'], string> = {
|
||||
Actif: 'var(--status-active)',
|
||||
'En développement': 'var(--status-experimental)',
|
||||
Historique: 'var(--status-historical)'
|
||||
};
|
||||
</script>
|
||||
|
||||
<section id="protocols" aria-labelledby="protocols-title">
|
||||
<ProtocolStream />
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<div class="content center stack">
|
||||
<header class="section-head stack">
|
||||
<p class="kicker">Standards & Protocoles</p>
|
||||
<h2 id="protocols-title">Les fondations du <span class="gradient">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
Comprendre les protocoles qui permettent la communication décentralisée entre les
|
||||
différentes plateformes.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid cards" role="list">
|
||||
{#each protocols as proto (proto.id)}
|
||||
<li>
|
||||
<article class="card" style:--type-color={TYPES[proto.type].color}>
|
||||
<div class="card-inner">
|
||||
<div class="card-media">
|
||||
<span class="icon-box">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if proto.id === 'activitypub'}
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
{:else if proto.id === 'activitystreams'}
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
|
||||
{:else if proto.id === 'webfinger'}
|
||||
<path d="M2 9.5a15 15 0 0 1 20 0" />
|
||||
<path d="M5 13a10 10 0 0 1 14 0" />
|
||||
<path d="M8.5 16.5a5 5 0 0 1 7 0" />
|
||||
<path d="M12 20h.01" />
|
||||
{:else if proto.id === 'httpsignatures'}
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
{:else if proto.id === 'nodeinfo'}
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" />
|
||||
<path d="M6 6h.01" />
|
||||
<path d="M6 18h.01" />
|
||||
{:else if proto.id === 'zot'}
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
{:else if proto.id === 'diaspora'}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
{:else if proto.id === 'ostatus'}
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
{:else if proto.id === 'matrix'}
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
{:else if proto.id === 'xmpp'}
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M4.93 19.07a10 10 0 0 1 0-14.14" />
|
||||
<path d="M7.76 16.24a6 6 0 0 1 0-8.49" />
|
||||
<path d="M16.24 7.76a6 6 0 0 1 0 8.49" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
{:else if proto.id === 'atproto'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="m10 13-2 2 2 2" />
|
||||
<path d="m14 17 2-2-2-2" />
|
||||
{:else if proto.id === 'nostr'}
|
||||
<circle cx="7.5" cy="15.5" r="5.5" />
|
||||
<path d="m21 2-9.6 9.6" />
|
||||
<path d="m15.5 7.5 3 3L22 7l-3-3" />
|
||||
{:else}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body stack">
|
||||
<div class="card-head cluster">
|
||||
<h3>{proto.name}</h3>
|
||||
<span class="badge">{TYPES[proto.type].label}</span>
|
||||
<span class="status" style:--status-color={STATUS_COLORS[proto.statut]}>
|
||||
<span class="dot" aria-hidden="true"></span>{proto.statut}
|
||||
</span>
|
||||
</div>
|
||||
<p class="description">{proto.description}</p>
|
||||
{#if proto.rfc}
|
||||
<span class="rfc">{proto.rfc}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ul class="legend cluster" role="list">
|
||||
{#each TYPE_ORDER as type (type)}
|
||||
<li class="cluster" style:--type-color={TYPES[type].color}>
|
||||
<span class="dot" aria-hidden="true"></span>{TYPES[type].label}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
/* Contexte sombre local : light-dark() bascule en mode sombre ici. */
|
||||
color-scheme: dark;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--ocean);
|
||||
color: var(--on-accent);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
/* Voile de lisibilité au-dessus du data stream */
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--ocean) 70%, transparent),
|
||||
color-mix(in oklab, var(--ocean) 55%, transparent)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
--stack-gap: var(--space-2);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent-2-text);
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-inline-size: var(--measure);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.cards {
|
||||
--grid-min: 20rem;
|
||||
--grid-gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
container-type: inline-size;
|
||||
block-size: 100%;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(0.5rem);
|
||||
-webkit-backdrop-filter: blur(0.5rem);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
transition:
|
||||
transform var(--duration-2) var(--ease-out),
|
||||
border-color var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--type-color);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* Container query : carte large → layout horizontal (icône à gauche) */
|
||||
@container (min-width: 28rem) {
|
||||
.card-inner {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.card-media {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--type-color);
|
||||
background: color-mix(in oklab, var(--type-color) 12%, transparent);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
--stack-gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--type-color);
|
||||
background: color-mix(in oklab, var(--type-color) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--type-color) 30%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--status-color, var(--type-color));
|
||||
box-shadow: 0 0 0.375rem var(--status-color, var(--type-color));
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.rfc {
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
background: color-mix(in oklab, var(--on-accent) 6%, transparent);
|
||||
border-radius: var(--radius-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.legend {
|
||||
--cluster-gap: var(--space-4);
|
||||
justify-content: center;
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.legend .dot {
|
||||
background: var(--type-color);
|
||||
box-shadow: 0 0 0.375rem var(--type-color);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
/* ProtocolStream — colonnes de « data stream » défilant en boucle,
|
||||
purement décoratives (aria-hidden). Aucune animation si mouvement réduit. */
|
||||
|
||||
const LINES = [
|
||||
'ActivityPub v1.0',
|
||||
'{ "type": "Create", "actor": "https://..." }',
|
||||
'W3C Recommendation',
|
||||
'WebFinger RFC 7033',
|
||||
'HTTP Signatures',
|
||||
'NodeInfo 2.0',
|
||||
'ActivityStreams 2.0',
|
||||
'application/activity+json',
|
||||
'followers_collection',
|
||||
'inbox → outbox',
|
||||
'FEP-8b32: Long Objects',
|
||||
'FEP-5624: Per-Event Push',
|
||||
'@user@instance.social',
|
||||
'fediverse:culture',
|
||||
'2026-01-14T09:30:00Z',
|
||||
'public_key: RSA 2048',
|
||||
'LD-Signatures',
|
||||
'content-type: application/ld+json',
|
||||
'as:Note, as:Article',
|
||||
'sharedInbox enabled'
|
||||
] as const;
|
||||
|
||||
/* Contenu doublé pour une boucle seamless (translateY(-50%) → 0) */
|
||||
const DOUBLED = [...LINES, ...LINES];
|
||||
|
||||
const COLUMNS = [
|
||||
{ left: '-10%', duration: '20s', delay: '0s' },
|
||||
{ left: '15%', duration: '28s', delay: '3s' },
|
||||
{ left: '40%', duration: '24s', delay: '7s' },
|
||||
{ left: '65%', duration: '32s', delay: '1s' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="stream" aria-hidden="true">
|
||||
{#each COLUMNS as col (col.left)}
|
||||
<div
|
||||
class="column"
|
||||
style:--left={col.left}
|
||||
style:--duration={col.duration}
|
||||
style:--delay={col.delay}
|
||||
>
|
||||
{#each DOUBLED as line, i (i)}
|
||||
<span class="line">{line}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stream {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: var(--left);
|
||||
inline-size: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--accent);
|
||||
opacity: 0.3;
|
||||
animation: stream-flow var(--duration) linear infinite;
|
||||
animation-delay: var(--delay);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.line {
|
||||
white-space: nowrap;
|
||||
padding-inline: var(--space-2);
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
@keyframes stream-flow {
|
||||
from {
|
||||
transform: translateY(-50%) skewX(-20deg);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0) skewX(-20deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.column {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { isTypingTarget } from '$lib/keyboard';
|
||||
|
||||
let open = $state(false);
|
||||
let dialogEl = $state<HTMLElement | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && open) {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
if (event.key === '?' && !isTypingTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
open = !open;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
dialogEl?.querySelector<HTMLElement>('button')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['/'], descKey: 'shortcuts.search' },
|
||||
{ keys: ['?'], descKey: 'shortcuts.help' },
|
||||
{ keys: ['j'], descKey: 'shortcuts.next' },
|
||||
{ keys: ['k'], descKey: 'shortcuts.prev' },
|
||||
{ keys: ['Échap'], descKey: 'shortcuts.close' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="backdrop"
|
||||
onclick={() => (open = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (open = false)}
|
||||
role="presentation"
|
||||
></div>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-title"
|
||||
bind:this={dialogEl}
|
||||
>
|
||||
<div class="dialog-header cluster">
|
||||
<h2 id="shortcuts-title">{t('shortcuts.title')}</h2>
|
||||
<button type="button" class="close-btn" onclick={() => (open = false)} aria-label={t('common.close')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="stack" role="list">
|
||||
{#each shortcuts as shortcut (shortcut.descKey)}
|
||||
<li class="shortcut-row">
|
||||
<span class="cluster keys">
|
||||
{#each shortcut.keys as key (key)}
|
||||
<kbd>{key}</kbd>
|
||||
{/each}
|
||||
</span>
|
||||
<span>{t(shortcut.descKey)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: oklch(10% 0.02 260 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
z-index: 90;
|
||||
inline-size: min(26rem, calc(100vw - 2 * var(--space-3)));
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
justify-content: space-between;
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.dialog ul {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: 2rem;
|
||||
min-block-size: 2rem;
|
||||
padding-inline: var(--space-2);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-block-end-width: 2px;
|
||||
border-radius: var(--radius-1);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,848 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { categoryById, ETATS, softwares, type Category, type Software } from '$lib/data';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { isTypingTarget } from '$lib/keyboard';
|
||||
|
||||
type StatusFilter = Software['etat'] | 'all';
|
||||
|
||||
const STORAGE_KEY = 'atlas-catalog-filters';
|
||||
|
||||
const statusColor: Record<Software['etat'], string> = {
|
||||
Actif: 'var(--status-active)',
|
||||
Maintenance: 'var(--status-maintenance)',
|
||||
Experimental: 'var(--status-experimental)',
|
||||
Historique: 'var(--status-historical)'
|
||||
};
|
||||
|
||||
const compatColor: Record<Software['compatibiliteAP'], string> = {
|
||||
Pleine: 'var(--compat-full)',
|
||||
Partielle: 'var(--compat-partial)',
|
||||
Extension: 'var(--compat-extension)',
|
||||
Aucune: 'var(--compat-none)',
|
||||
'En développement': 'var(--compat-none)'
|
||||
};
|
||||
|
||||
/** Choisit un texte contrasté (clair ou foncé) selon la luminance WCAG d'une couleur hex. */
|
||||
function contrastingInk(hex: string | undefined): string {
|
||||
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return 'var(--on-accent)';
|
||||
const channels = [1, 3, 5].map((start) => {
|
||||
const c = parseInt(hex.slice(start, start + 2), 16) / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
const luminance = 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
return luminance > 0.18 ? 'oklch(22% 0.02 260)' : 'var(--on-accent)';
|
||||
}
|
||||
|
||||
function categoryOf(software: Software): Category | undefined {
|
||||
return categoryById.get(software.category);
|
||||
}
|
||||
|
||||
function categoryName(software: Software): string {
|
||||
return categoryOf(software)?.name ?? software.category;
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let activeCategory = $state('all');
|
||||
let activeStatus = $state<StatusFilter>('all');
|
||||
let selectedSoftware = $state<Software | null>(null);
|
||||
|
||||
let searchInput = $state<HTMLInputElement | null>(null);
|
||||
let gridEl = $state<HTMLElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let triggerElement: HTMLElement | null = null;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const result = softwares.filter((software) => {
|
||||
if (activeCategory !== 'all' && software.category !== activeCategory) return false;
|
||||
if (activeStatus !== 'all' && software.etat !== activeStatus) return false;
|
||||
if (query) {
|
||||
const haystack =
|
||||
`${software.name} ${software.description} ${software.category} ${categoryName(software)} ${software.language}`.toLowerCase();
|
||||
if (!haystack.includes(query)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
result.sort((a, b) => {
|
||||
const statusDiff = ETATS.indexOf(a.etat) - ETATS.indexOf(b.etat);
|
||||
return statusDiff !== 0 ? statusDiff : a.name.localeCompare(b.name);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
function resetFilters(): void {
|
||||
search = '';
|
||||
activeCategory = 'all';
|
||||
activeStatus = 'all';
|
||||
}
|
||||
|
||||
function openDetails(software: Software, trigger: HTMLElement | null): void {
|
||||
triggerElement = trigger;
|
||||
selectedSoftware = software;
|
||||
}
|
||||
|
||||
function closeDetails(): void {
|
||||
selectedSoftware = null;
|
||||
triggerElement?.focus();
|
||||
triggerElement = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Reprennabilité : restaure les filtres de la session
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof saved.search === 'string') search = saved.search;
|
||||
if (
|
||||
saved.activeCategory === 'all' ||
|
||||
(typeof saved.activeCategory === 'string' && categoryById.has(saved.activeCategory))
|
||||
) {
|
||||
activeCategory = saved.activeCategory as string;
|
||||
}
|
||||
if (
|
||||
saved.activeStatus === 'all' ||
|
||||
(typeof saved.activeStatus === 'string' &&
|
||||
(ETATS as readonly string[]).includes(saved.activeStatus))
|
||||
) {
|
||||
activeStatus = saved.activeStatus as StatusFilter;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stockage indisponible ou données corrompues : on ignore */
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (selectedSoftware) closeDetails();
|
||||
return;
|
||||
}
|
||||
if (selectedSoftware) return;
|
||||
if (isTypingTarget(event.target) || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
|
||||
if (event.key === '/') {
|
||||
event.preventDefault();
|
||||
searchInput?.focus();
|
||||
} else if (event.key === 'j' || event.key === 'k') {
|
||||
const cards = gridEl ? [...gridEl.querySelectorAll<HTMLElement>('.card')] : [];
|
||||
if (cards.length === 0) return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'j' ? 1 : -1;
|
||||
const current = cards.indexOf(document.activeElement as HTMLElement);
|
||||
const next =
|
||||
current === -1
|
||||
? delta === 1
|
||||
? 0
|
||||
: cards.length - 1
|
||||
: (current + delta + cards.length) % cards.length;
|
||||
cards[next].focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
// Persiste les filtres pour la session
|
||||
$effect(() => {
|
||||
const snapshot = { search, activeCategory, activeStatus };
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
/* stockage indisponible : on ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Focus sur le bouton fermer à l'ouverture de la modale
|
||||
$effect(() => {
|
||||
if (selectedSoftware) closeButton?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="catalog" aria-labelledby="catalog-title">
|
||||
<div class="inner center">
|
||||
<header class="section-header stack">
|
||||
<p class="kicker">Répertoire</p>
|
||||
<h2 id="catalog-title">Les logiciels du <span class="gradient-text">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
{softwares.length} logiciels référencés, classés par catégorie et filtrables par statut
|
||||
d'activité.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-field">
|
||||
<label class="visually-hidden" for="catalog-search">{t('common.search')}</label>
|
||||
<svg
|
||||
class="search-icon"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
|
||||
<path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
id="catalog-search"
|
||||
type="search"
|
||||
bind:this={searchInput}
|
||||
bind:value={search}
|
||||
placeholder="Rechercher un logiciel, un langage…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if search}
|
||||
<button
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
aria-label={t('common.clear')}
|
||||
onclick={() => {
|
||||
search = '';
|
||||
searchInput?.focus();
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="selects cluster">
|
||||
<label class="select-label">
|
||||
<span class="visually-hidden">Filtrer par catégorie</span>
|
||||
<select bind:value={activeCategory}>
|
||||
<option value="all">Toutes les catégories</option>
|
||||
{#each [...categoryById.values()] as category (category.id)}
|
||||
<option value={category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="select-label">
|
||||
<span class="visually-hidden">Filtrer par statut</span>
|
||||
<select bind:value={activeStatus}>
|
||||
<option value="all">Tous les statuts</option>
|
||||
{#each ETATS as etat (etat)}
|
||||
<option value={etat}>{etat}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="results-count" role="status">
|
||||
{t('catalog.results', { count: filtered.length })}<!--
|
||||
-->{#if activeCategory !== 'all'}
|
||||
dans <strong>{categoryById.get(activeCategory)?.name ?? activeCategory}</strong>{/if}<!--
|
||||
-->{#if activeStatus !== 'all'}
|
||||
· statut : <strong>{activeStatus}</strong>{/if}<!--
|
||||
-->{#if search.trim()}
|
||||
pour « <strong>{search.trim()}</strong> »{/if}
|
||||
</p>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty stack">
|
||||
<p>{t('common.emptyResults')}</p>
|
||||
<button type="button" class="reset-btn" onclick={resetFilters}>
|
||||
{t('common.resetFilters')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid catalog-grid" bind:this={gridEl}>
|
||||
{#each filtered as software (software.id)}
|
||||
{@const category = categoryOf(software)}
|
||||
<button
|
||||
type="button"
|
||||
class="card"
|
||||
style:--cat-color={category?.color ?? 'var(--muted)'}
|
||||
style:--tile-ink={contrastingInk(category?.color)}
|
||||
style:--status-color={statusColor[software.etat]}
|
||||
style:--compat-color={compatColor[software.compatibiliteAP]}
|
||||
aria-haspopup="dialog"
|
||||
onclick={(event) => openDetails(software, event.currentTarget)}
|
||||
>
|
||||
<span class="card-inner">
|
||||
<span class="tile" aria-hidden="true">{software.name.charAt(0)}</span>
|
||||
<span class="card-body">
|
||||
<span class="card-head">
|
||||
<span class="card-name">{software.name}</span>
|
||||
<span class="card-cat">{categoryName(software)}</span>
|
||||
</span>
|
||||
<span class="status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
{software.etat}
|
||||
</span>
|
||||
<span class="card-desc">{software.description}</span>
|
||||
<span class="chips">
|
||||
<span class="chip">{software.language}</span>
|
||||
<span class="chip compat">AP : {software.compatibiliteAP}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="end-marker">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
<path d="M12 8v13" />
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3" />
|
||||
</svg>
|
||||
{t('catalog.upToDate')} · {t('catalog.upToDateCreole')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if selectedSoftware}
|
||||
{@const software = selectedSoftware}
|
||||
{@const category = categoryOf(software)}
|
||||
<div class="backdrop" onclick={closeDetails} role="presentation"></div>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="software-dialog-title"
|
||||
style:--cat-color={category?.color ?? 'var(--muted)'}
|
||||
style:--tile-ink={contrastingInk(category?.color)}
|
||||
style:--status-color={statusColor[software.etat]}
|
||||
style:--compat-color={compatColor[software.compatibiliteAP]}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
bind:this={closeButton}
|
||||
aria-label={t('common.close')}
|
||||
onclick={closeDetails}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="dialog-head">
|
||||
<span class="tile tile-lg" aria-hidden="true">{software.name.charAt(0)}</span>
|
||||
<span class="dialog-title-block">
|
||||
<h3 id="software-dialog-title">{software.name}</h3>
|
||||
<span class="badges">
|
||||
<span class="chip cat-chip">{categoryName(software)}</span>
|
||||
<span class="status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
{software.etat}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="dialog-desc">{software.description}</p>
|
||||
|
||||
<dl class="details">
|
||||
<div class="detail-row">
|
||||
<dt>Protocoles</dt>
|
||||
<dd>{software.protocols.join(', ')}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>Licence</dt>
|
||||
<dd>{software.licence}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>Langage</dt>
|
||||
<dd>{software.language}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>ActivityPub</dt>
|
||||
<dd class="compat-value">{software.compatibiliteAP}</dd>
|
||||
</div>
|
||||
{#if software.version}
|
||||
<div class="detail-row">
|
||||
<dt>Version</dt>
|
||||
<dd>{software.version}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if software.createdYear}
|
||||
<div class="detail-row">
|
||||
<dt>Créé en</dt>
|
||||
<dd>{software.createdYear}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="dialog-actions">
|
||||
{#if software.site}
|
||||
<a href={software.site} target="_blank" rel="external noopener" class="btn-primary">
|
||||
{t('common.officialSite')}
|
||||
</a>
|
||||
{/if}
|
||||
{#if software.github}
|
||||
<a href={software.github} target="_blank" rel="external noopener" class="btn-outline">
|
||||
{t('common.sourceCode')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--surface);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--ink-soft);
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
/* --- Barre d'outils --- */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
flex: 1 1 18rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--space-3);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline-start: calc(var(--space-3) * 2 + 1.125rem);
|
||||
padding-inline-end: var(--tap-target);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--ink);
|
||||
transition: border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.search-field input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-field input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--space-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: calc(var(--tap-target) - var(--space-2));
|
||||
block-size: calc(var(--tap-target) - var(--space-2));
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.selects {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.select-label select {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-label select:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Compteur --- */
|
||||
|
||||
.results-count {
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.results-count strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* --- État vide --- */
|
||||
|
||||
.empty {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding-block: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--accent-text);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
/* --- Grille et cartes --- */
|
||||
|
||||
.catalog-grid {
|
||||
--grid-min: 19rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
container-type: inline-size;
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color var(--duration-1) var(--ease-out),
|
||||
box-shadow var(--duration-1) var(--ease-out),
|
||||
transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: color-mix(in oklch, var(--cat-color) 55%, var(--glass-border));
|
||||
box-shadow: var(--shadow-2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.75rem;
|
||||
block-size: 2.75rem;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--cat-color);
|
||||
color: var(--tile-ink);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.card-cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--status-color);
|
||||
box-shadow: 0 0 0.4rem color-mix(in oklch, var(--status-color) 60%, transparent);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.chip.compat {
|
||||
background: color-mix(in oklch, var(--compat-color) 14%, transparent);
|
||||
border-color: color-mix(in oklch, var(--compat-color) 45%, transparent);
|
||||
color: var(--compat-color);
|
||||
}
|
||||
|
||||
/* Container query : tuile au-dessus quand la carte est étroite */
|
||||
@container (max-width: 15rem) {
|
||||
.card-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Marqueur de fin (pas de scroll infini) --- */
|
||||
|
||||
.end-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-5);
|
||||
padding-block-start: var(--space-4);
|
||||
border-block-start: 1px solid var(--border);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* --- Modale détail --- */
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: oklch(10% 0.02 260 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
z-index: 90;
|
||||
inline-size: min(32rem, calc(100vw - 2 * var(--space-3)));
|
||||
max-block-size: 85dvh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
inset-block-start: var(--space-2);
|
||||
inset-inline-end: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-block-end: var(--space-3);
|
||||
padding-inline-end: var(--tap-target);
|
||||
}
|
||||
|
||||
.tile-lg {
|
||||
inline-size: 3.5rem;
|
||||
block-size: 3.5rem;
|
||||
font-size: var(--text-3);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.dialog-title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
background: color-mix(in oklch, var(--cat-color) 16%, transparent);
|
||||
border-color: color-mix(in oklch, var(--cat-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.dialog-desc {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.details {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-2);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-row dt {
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-row dd {
|
||||
color: var(--ink);
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compat-value {
|
||||
color: var(--compat-color);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.dialog-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1 1 auto;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { networkData, stats } from '$lib/data';
|
||||
import { timelineEvents, type TimelineEvent } from '$lib/data/timeline';
|
||||
|
||||
type CategoryKey = TimelineEvent['category'];
|
||||
type FilterKey = CategoryKey | 'all';
|
||||
|
||||
/* Couleurs de catégories : tokens sémantiques (adaptés au thème clair/sombre). */
|
||||
const categoryConfig = {
|
||||
foundation: { label: 'Fondation', color: 'var(--muted)' },
|
||||
protocol: { label: 'Protocole', color: 'var(--accent)' },
|
||||
software: { label: 'Logiciel', color: 'var(--accent-2)' },
|
||||
milestone: { label: 'Jalon', color: 'var(--status-maintenance)' },
|
||||
ecosystem: { label: 'Écosystème', color: 'var(--accent-4)' }
|
||||
} as const satisfies Record<CategoryKey, { label: string; color: string }>;
|
||||
|
||||
const categoryFilters: readonly { key: FilterKey; label: string; color: string }[] = [
|
||||
{ key: 'all', label: 'Tout', color: 'var(--accent)' },
|
||||
{ key: 'foundation', label: 'Fondations', color: categoryConfig.foundation.color },
|
||||
{ key: 'protocol', label: 'Protocoles', color: categoryConfig.protocol.color },
|
||||
{ key: 'software', label: 'Logiciels', color: categoryConfig.software.color },
|
||||
{ key: 'milestone', label: 'Jalons', color: categoryConfig.milestone.color },
|
||||
{ key: 'ecosystem', label: 'Écosystème', color: categoryConfig.ecosystem.color }
|
||||
];
|
||||
|
||||
let activeFilter = $state<FilterKey>('all');
|
||||
|
||||
const filtered = $derived(
|
||||
activeFilter === 'all'
|
||||
? timelineEvents
|
||||
: timelineEvents.filter((event) => event.category === activeFilter)
|
||||
);
|
||||
|
||||
const yearGroups = $derived.by(() => {
|
||||
const byYear = new Map<number, TimelineEvent[]>();
|
||||
for (const event of filtered) {
|
||||
const group = byYear.get(event.year);
|
||||
if (group) group.push(event);
|
||||
else byYear.set(event.year, [event]);
|
||||
}
|
||||
return [...byYear.entries()].sort((a, b) => a[0] - b[0]);
|
||||
});
|
||||
|
||||
const finalStats = [
|
||||
{ value: stats.yearsOfHistory, label: "Années d'histoire" },
|
||||
{ value: stats.totalSoftwares, label: 'Logiciels référencés' },
|
||||
{ value: stats.totalProtocols, label: 'Protocoles' },
|
||||
{ value: networkData.links.length, label: 'Liens de fédération' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section id="timeline" aria-labelledby="timeline-title">
|
||||
<div class="inner center">
|
||||
<header class="section-header stack">
|
||||
<p class="kicker">🕐 Chronologie</p>
|
||||
<h2 id="timeline-title">L'évolution du <span class="gradient-text">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
De StatusNet en 2008 à l'Atlas de 2026 : {stats.yearsOfHistory} ans d'histoire du réseau
|
||||
social décentralisé.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters cluster" role="group" aria-label="Filtrer la chronologie par catégorie">
|
||||
{#each categoryFilters as filter (filter.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
style:--cat-color={filter.color}
|
||||
aria-pressed={activeFilter === filter.key}
|
||||
onclick={() => (activeFilter = filter.key)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
{#each yearGroups as [year, events] (year)}
|
||||
<section class="year-group" aria-labelledby="timeline-year-{year}">
|
||||
<h3 class="year-badge" id="timeline-year-{year}">{year}</h3>
|
||||
<ol class="events" role="list">
|
||||
{#each events as event, i (`${year}-${event.title}`)}
|
||||
{@const cat = categoryConfig[event.category]}
|
||||
<li class="event" class:left={i % 2 === 0} class:right={i % 2 === 1}>
|
||||
<article class="event-card" style:--event-color={event.color}>
|
||||
<p class="event-meta">
|
||||
<span class="event-dot" aria-hidden="true"></span>
|
||||
<span class="event-cat" style:--cat-label-color={cat.color}>{cat.label}</span>
|
||||
</p>
|
||||
<h4 class="event-title">{event.title}</h4>
|
||||
<p class="event-desc">{event.description}</p>
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid stats" role="list">
|
||||
{#each finalStats as stat (stat.label)}
|
||||
<div class="stat-card" role="listitem">
|
||||
<p class="stat-value gradient-text">{stat.value}</p>
|
||||
<p class="stat-label">{stat.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--abyss);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.inner {
|
||||
--center-max: 64rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--status-maintenance);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--ink-soft);
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
/* --- Filtres --- */
|
||||
|
||||
.filters {
|
||||
justify-content: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 500;
|
||||
color: var(--ink-soft);
|
||||
transition:
|
||||
background-color var(--duration-1) var(--ease-out),
|
||||
border-color var(--duration-1) var(--ease-out),
|
||||
color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
border-color: var(--cat-color);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pill[aria-pressed='true'] {
|
||||
background: color-mix(in oklch, var(--cat-color) 22%, transparent);
|
||||
border-color: var(--cat-color);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Chronologie --- */
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Ligne verticale en dégradé : à gauche sur mobile, centrée sur desktop */
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--space-3);
|
||||
translate: -50% 0;
|
||||
inline-size: 2px;
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2), var(--accent-4));
|
||||
}
|
||||
|
||||
.year-group {
|
||||
position: relative;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
inline-size: fit-content;
|
||||
margin-inline: auto;
|
||||
margin-block-end: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.event {
|
||||
position: relative;
|
||||
padding-inline-start: var(--space-5);
|
||||
}
|
||||
|
||||
/* Pastille sur la ligne */
|
||||
.event::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: var(--space-3);
|
||||
inset-block-start: var(--space-4);
|
||||
translate: -50% 0;
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--event-color, var(--accent));
|
||||
border: 2px solid var(--abyss);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--event-color, var(--accent)) 45%, transparent);
|
||||
}
|
||||
|
||||
.event-card {
|
||||
inline-size: fit-content;
|
||||
max-inline-size: 100%;
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
flex-shrink: 0;
|
||||
inline-size: 0.5rem;
|
||||
block-size: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--event-color, var(--accent));
|
||||
box-shadow: 0 0 0.5rem color-mix(in oklch, var(--event-color, var(--accent)) 70%, transparent);
|
||||
}
|
||||
|
||||
.event-cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--cat-label-color);
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: var(--text-1);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* Alternance gauche/droite sur desktop */
|
||||
@media (min-width: 48rem) {
|
||||
.timeline::before {
|
||||
inset-inline-start: 50%;
|
||||
}
|
||||
|
||||
.event {
|
||||
inline-size: calc(50% - var(--space-5));
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.event.left {
|
||||
margin-inline-end: auto;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.event.left .event-card {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.event.left .event-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.event.left::before {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: calc(-1 * var(--space-5));
|
||||
translate: 50% 0;
|
||||
}
|
||||
|
||||
.event.right {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.event.right::before {
|
||||
inset-inline-start: calc(-1 * var(--space-5));
|
||||
}
|
||||
}
|
||||
|
||||
/* Container query : carte compacte quand la chronologie est étroite */
|
||||
@container (max-width: 30rem) {
|
||||
.event-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Statistiques finales --- */
|
||||
|
||||
.stats {
|
||||
--grid-min: 11rem;
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: var(--space-3);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
export interface CompareRow {
|
||||
aspect: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Légende visible du tableau (sert aussi de description accessible). */
|
||||
caption: string;
|
||||
/** Libellé de la première colonne (en-têtes de ligne). */
|
||||
firstColumn?: string;
|
||||
/** En-têtes des colonnes de données. */
|
||||
columns: string[];
|
||||
rows: CompareRow[];
|
||||
/** Index dans `columns` de la colonne mise en valeur (-1 = aucune). */
|
||||
highlight?: number;
|
||||
/** Étiquette de la région de défilement horizontal. */
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
let { caption, firstColumn = 'Aspect', columns, rows, highlight = -1, ariaLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex — région défilante : tabindex="0" la rend
|
||||
accessible au clavier (défilement horizontal du tableau), c'est voulu. -->
|
||||
<div class="table-scroll" role="region" aria-label={ariaLabel} tabindex="0">
|
||||
<table>
|
||||
<caption>{caption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{firstColumn}</th>
|
||||
{#each columns as column, i (column)}
|
||||
<th scope="col" class:highlight={i === highlight}>{column}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.aspect)}
|
||||
<tr>
|
||||
<th scope="row">{row.aspect}</th>
|
||||
{#each row.values as value, i (i)}
|
||||
<td class:highlight={i === highlight}>{value}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
table {
|
||||
inline-size: 100%;
|
||||
min-inline-size: 34rem;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
caption {
|
||||
text-align: start;
|
||||
padding: var(--space-3) var(--space-3) 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
|
||||
tbody tr:last-child th,
|
||||
tbody tr:last-child td {
|
||||
border-block-end: 0;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
border-block-end-color: var(--border-strong);
|
||||
}
|
||||
|
||||
tbody th {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* Colonne mise en valeur : couleur accent ET fond teinté + texte en gras
|
||||
(l'information n'est jamais portée par la couleur seule côté contenu). */
|
||||
th.highlight,
|
||||
td.highlight {
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 9%, transparent);
|
||||
}
|
||||
|
||||
thead th.highlight {
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
}
|
||||
|
||||
td.highlight {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
tbody tr:hover th,
|
||||
tbody tr:hover td {
|
||||
background: color-mix(in oklch, var(--ink) 5%, transparent);
|
||||
}
|
||||
|
||||
tbody tr:hover td.highlight {
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
export interface FeatureItem {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: FeatureItem[];
|
||||
/** Taille minimale des colonnes de la grille (primitive .grid). */
|
||||
min?: string;
|
||||
}
|
||||
|
||||
let { items, min = '15rem' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="feature-cq">
|
||||
<ul class="grid" role="list" style:--grid-min={min}>
|
||||
{#each items as item (item.title)}
|
||||
<li class="feature-card">
|
||||
<span class="feature-icon"><Icon name={item.icon} /></span>
|
||||
<div class="feature-text">
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container query : la carte passe en disposition horizontale
|
||||
quand son conteneur (pas le viewport) est assez large. */
|
||||
.feature-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.75rem;
|
||||
block-size: 2.75rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 30rem) {
|
||||
.feature-card {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" module>
|
||||
/* Icônes SVG inline (24×24, trait currentColor) — tracés statiques locaux,
|
||||
aucune dépendance. Contenu interne de confiance, injecté via {@html}. */
|
||||
const ICONS = {
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11.2 12.2 20 3.5"/><path d="m16 7 3 3"/>',
|
||||
server:
|
||||
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/>',
|
||||
'server-off':
|
||||
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/><path d="m3 3 18 18"/>',
|
||||
lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
zap: '<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>',
|
||||
bitcoin:
|
||||
'<path d="M9 4h4a3 3 0 0 1 0 6H9V4z"/><path d="M9 10h5a3 3 0 0 1 0 6H9v-6z"/><path d="M11 2v2"/><path d="M14 2v2"/><path d="M11 20v-2"/><path d="M14 20v-2"/>',
|
||||
radio:
|
||||
'<circle cx="12" cy="12" r="1.5"/><path d="M8.5 8.5a5 5 0 0 0 0 7"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M5.6 5.6a9 9 0 0 0 0 12.8"/><path d="M18.4 5.6a9 9 0 0 1 0 12.8"/>',
|
||||
globe:
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a13.5 13.5 0 0 1 0 18"/><path d="M12 3a13.5 13.5 0 0 0 0 18"/>',
|
||||
message:
|
||||
'<path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 20l1.2-5A8.5 8.5 0 1 1 21 11.5z"/>',
|
||||
external:
|
||||
'<path d="M14 4h6v6"/><path d="M20 4 10 14"/><path d="M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6"/>',
|
||||
github:
|
||||
'<path d="M15 21v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.6 1.3a12.3 12.3 0 0 0-6.3 0C6.2 2.8 5.1 3.1 5.1 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/><path d="M9 18c-4.5 1.5-4.5-2.5-6-3"/>',
|
||||
shield: '<path d="M12 2 4 5.5v5.6c0 4.9 3.4 9.3 8 10.9 4.6-1.6 8-6 8-10.9V5.5L12 2z"/>',
|
||||
'user-x':
|
||||
'<circle cx="9" cy="8" r="4"/><path d="M2.5 21v-1a6.5 6.5 0 0 1 11-4.7"/><path d="m17 10 5 5"/><path d="m22 10-5 5"/>',
|
||||
'eye-off':
|
||||
'<path d="m3 3 18 18"/><path d="M10.5 5.2A9.8 9.8 0 0 1 12 5c7 0 10 7 10 7a17.4 17.4 0 0 1-2.9 3.6"/><path d="M6.6 6.6A16.9 16.9 0 0 0 2 12s3 7 10 7a10 10 0 0 0 4.3-1"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/>',
|
||||
fingerprint:
|
||||
'<path d="M6.8 6.8A7 7 0 0 1 19 13c0 2.8-.4 5.4-1.1 7.5"/><path d="M5.2 10A7 7 0 0 0 5 13c0 3-.9 5.2-1.9 6.7"/><path d="M12 11a2 2 0 0 0-2 2c0 2.6-.6 5-1.7 7"/><path d="M14 13a2 2 0 0 0-2-2"/><path d="M14 13c0 2.9-.3 5.5-1 8"/>',
|
||||
phone:
|
||||
'<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 2 .7 2.9a2 2 0 0 1-.4 2.1L8.1 10a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.9.6 2.9.7a2 2 0 0 1 1.7 2z"/>',
|
||||
users:
|
||||
'<circle cx="9" cy="8" r="4"/><path d="M2 21v-1a7 7 0 0 1 14 0v1"/><path d="M16.5 4.2a4 4 0 0 1 0 7.6"/><path d="M18 14.3A7 7 0 0 1 22 20v1"/>',
|
||||
chevron: '<path d="m6 9 6 6 6-6"/>'
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, size = 20 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html ICONS[name]}
|
||||
</svg>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
export interface StepItem {
|
||||
step: string;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: StepItem[];
|
||||
}
|
||||
|
||||
let { items }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="steps-cq">
|
||||
<ol class="grid" role="list" style:--grid-min="17rem">
|
||||
{#each items as item (item.step)}
|
||||
<li class="step-card">
|
||||
<span class="step-number" aria-hidden="true">{item.step}</span>
|
||||
<span class="step-icon"><Icon name={item.icon} size={24} /></span>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.description}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container query : rythme plus généreux quand le conteneur s'élargit. */
|
||||
.steps-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-4);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: color-mix(in oklch, var(--page-accent, var(--accent)) 25%, transparent);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 45rem) {
|
||||
.step-card {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
export interface Bridge {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fromProtocol: string;
|
||||
toProtocol: string;
|
||||
fromColor: string;
|
||||
toColor: string;
|
||||
status: 'Actif' | 'En développement' | 'Expérimental' | 'Planifié';
|
||||
url?: string;
|
||||
github?: string;
|
||||
details: string;
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export const bridges: Bridge[] = [
|
||||
{
|
||||
id: 'mostr',
|
||||
name: 'Mostr',
|
||||
description: 'Pont unidirectionnel ActivityPub → Nostr. Permet de suivre des comptes ActivityPub depuis Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'Actif',
|
||||
url: 'https://mostr.pub/',
|
||||
github: 'https://github.com/gfanton/mostr',
|
||||
details: 'Mostr relay publie les activités ActivityPub sur Nostr. Créé par Guilhem Fanton (gfanton). Les utilisateurs Nostr peuvent suivre des comptes Mastodon/Pleroma via des clés npub dérivées.',
|
||||
limitations: [
|
||||
'Unidirectionnel (AP → Nostr uniquement)',
|
||||
'Ne supporte pas encore les médias riches',
|
||||
'Dépend de la disponibilité des instances AP',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nostr-ap-bridge',
|
||||
name: 'Nostr-AP Bridge',
|
||||
description: 'Pont bidirectionnel expérimental entre Nostr et ActivityPub. Synchronise notes et réponses.',
|
||||
fromProtocol: 'Nostr',
|
||||
toProtocol: 'ActivityPub',
|
||||
fromColor: '#F7931A',
|
||||
toColor: '#F72585',
|
||||
status: 'Expérimental',
|
||||
github: 'https://github.com/nostr-protocol/nostr',
|
||||
details: 'Implémentation expérimentale permettant la publication croisée entre les deux protocoles. Les notes Nostr apparaissent comme des posts ActivityPub et vice-versa.',
|
||||
limitations: [
|
||||
'En phase expérimentale précoce',
|
||||
'Problèmes de formatage des contenus riches',
|
||||
'Gestion des threads complexe',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'activitypub-nostr-gateway',
|
||||
name: 'AP-Nostr Gateway',
|
||||
description: 'Passerelle permettant aux instances ActivityPub de communiquer avec le réseau Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'En développement',
|
||||
github: 'https://github.com/fediverse',
|
||||
details: 'Projet communautaire visant à créer une passerelle standard entre ActivityPub et Nostr. Supporte la conversion de formats d\'activités.',
|
||||
limitations: [
|
||||
'Non standardisé',
|
||||
'Performance limitée',
|
||||
'Nécessite un relay dédié',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fedify-nostr',
|
||||
name: 'Fedify + Nostr',
|
||||
description: 'Le framework Fedify explore le support multi-protocoles incluant Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'Planifié',
|
||||
url: 'https://fedify.dev/',
|
||||
github: 'https://github.com/dahlia/fedify',
|
||||
details: 'Fedify, le framework TypeScript pour ActivityPub, envisage d\'ajouter le support Nostr pour permettre aux développeurs de construire des applications multi-protocoles.',
|
||||
limitations: [
|
||||
'En phase de planification',
|
||||
'Pas de release date annoncée',
|
||||
'Complexité technique élevée',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'soapbox-nostr',
|
||||
name: 'Soapbox Nostr Bridge',
|
||||
description: 'Pont intégré à Soapbox (frontend Pleroma) pour la cross-publication Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'En développement',
|
||||
url: 'https://soapbox.pub/',
|
||||
details: 'Soapbox, le frontend alternatif pour Pleroma, développe un module de bridge Nostr permettant aux utilisateurs de cross-publier leurs contenus.',
|
||||
limitations: [
|
||||
'Spécifique à Soapbox/Pleroma',
|
||||
'Configuration requise par l\'administrateur',
|
||||
'Support partiel des formats',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const bridgeProtocolOrder = [
|
||||
{ name: 'ActivityPub', color: '#F72585' },
|
||||
{ name: 'Nostr', color: '#F7931A' },
|
||||
{ name: 'XMPP', color: '#06D6A0' },
|
||||
{ name: 'Matrix', color: '#4361EE' },
|
||||
{ name: 'AT Protocol', color: '#4CC9F0' },
|
||||
{ name: 'SimpleX', color: '#2EC4B6' },
|
||||
];
|
||||
|
||||
export const bridgeStatusInfo: Record<string, { label: string; color: string }> = {
|
||||
Actif: { label: '✅ Actif', color: '#2EC4B6' },
|
||||
'En développement': { label: '🔧 En développement', color: '#F9C74F' },
|
||||
Expérimental: { label: '🧪 Expérimental', color: '#F7931A' },
|
||||
Planifié: { label: '📋 Planifié', color: '#4CC9F0' },
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"id": "social",
|
||||
"name": "Réseaux sociaux",
|
||||
"icon": "Users",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Plateformes sociales fédérées"
|
||||
},
|
||||
{
|
||||
"id": "microblog",
|
||||
"name": "Microblogging",
|
||||
"icon": "MessageSquare",
|
||||
"color": "#F9C74F",
|
||||
"description": "Micro-blogs et status updates"
|
||||
},
|
||||
{
|
||||
"id": "photo",
|
||||
"name": "Photographie",
|
||||
"icon": "Camera",
|
||||
"color": "#F8961E",
|
||||
"description": "Partage de photos et images"
|
||||
},
|
||||
{
|
||||
"id": "video",
|
||||
"name": "Vidéo",
|
||||
"icon": "Video",
|
||||
"color": "#F72585",
|
||||
"description": "Streaming et partage vidéo"
|
||||
},
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "Audio & Podcast",
|
||||
"icon": "Headphones",
|
||||
"color": "#B5179E",
|
||||
"description": "Musique, podcasts et audio"
|
||||
},
|
||||
{
|
||||
"id": "blog",
|
||||
"name": "Blogs",
|
||||
"icon": "FileText",
|
||||
"color": "#7209B7",
|
||||
"description": "Plateformes de blogging"
|
||||
},
|
||||
{
|
||||
"id": "forum",
|
||||
"name": "Forums",
|
||||
"icon": "MessageCircle",
|
||||
"color": "#3A0CA3",
|
||||
"description": "Forums et agrégateurs"
|
||||
},
|
||||
{
|
||||
"id": "events",
|
||||
"name": "Événements",
|
||||
"icon": "Calendar",
|
||||
"color": "#4361EE",
|
||||
"description": "Gestion d'événements"
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Développement",
|
||||
"icon": "Code",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Forge et développement logiciel"
|
||||
},
|
||||
{
|
||||
"id": "collab",
|
||||
"name": "Collaboration",
|
||||
"icon": "FolderOpen",
|
||||
"color": "#2EC4B6",
|
||||
"description": "Outils collaboratifs et cloud"
|
||||
},
|
||||
{
|
||||
"id": "books",
|
||||
"name": "Livres",
|
||||
"icon": "BookOpen",
|
||||
"color": "#E71D36",
|
||||
"description": "Lecture et partage de livres"
|
||||
},
|
||||
{
|
||||
"id": "wiki",
|
||||
"name": "Wiki",
|
||||
"icon": "Globe",
|
||||
"color": "#662E9B",
|
||||
"description": "Encyclopédies collaboratives"
|
||||
},
|
||||
{
|
||||
"id": "messaging",
|
||||
"name": "Messagerie",
|
||||
"icon": "Send",
|
||||
"color": "#06D6A0",
|
||||
"description": "Messagerie instantanée privée et sécurisée"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"icon": "Zap",
|
||||
"color": "#F7931A",
|
||||
"description": "Écosystème Nostr — protocole voisin"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
categories,
|
||||
categoryById,
|
||||
networkData,
|
||||
protocols,
|
||||
softwares,
|
||||
stats
|
||||
} from './index';
|
||||
import { timelineEvents } from './timeline';
|
||||
import { bridges } from './bridges';
|
||||
|
||||
describe('données de l’Atlas', () => {
|
||||
it('référence 105 logiciels, 12 protocoles, 14 catégories', () => {
|
||||
expect(softwares).toHaveLength(105);
|
||||
expect(protocols).toHaveLength(12);
|
||||
expect(categories).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('chaque logiciel pointe vers une catégorie existante', () => {
|
||||
for (const software of softwares) {
|
||||
expect(categoryById.has(software.category)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('les compteurs dérivés sont cohérents avec les métadonnées', () => {
|
||||
expect(stats.totalSoftwares).toBe(105);
|
||||
expect(stats.activeSoftwares).toBe(93);
|
||||
});
|
||||
|
||||
it('le graphe réseau est déterministe et couvre les 105 logiciels', () => {
|
||||
expect(networkData.nodes).toHaveLength(105);
|
||||
expect(networkData.links.length).toBeGreaterThan(0);
|
||||
const nodeIds = new Set(networkData.nodes.map((n) => n.id));
|
||||
for (const link of networkData.links) {
|
||||
const source = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const target = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
expect(nodeIds.has(source)).toBe(true);
|
||||
expect(nodeIds.has(target)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('la chronologie couvre 2008 → 2026 dans l’ordre croissant', () => {
|
||||
expect(timelineEvents.length).toBeGreaterThanOrEqual(30);
|
||||
expect(timelineEvents[0].year).toBe(2008);
|
||||
for (let i = 1; i < timelineEvents.length; i++) {
|
||||
expect(timelineEvents[i].year).toBeGreaterThanOrEqual(timelineEvents[i - 1].year);
|
||||
}
|
||||
});
|
||||
|
||||
it('les ponts Nostr ↔ ActivityPub sont présents', () => {
|
||||
expect(bridges.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Point d'entrée des données de l'Atlas — modules JSON typés.
|
||||
Source de vérité : /public/data du projet v4 (réutilisés tels quels). */
|
||||
|
||||
import softwareJson from './software.json';
|
||||
import protocolsJson from './protocols.json';
|
||||
import categoriesJson from './categories.json';
|
||||
import networkJson from './network.json';
|
||||
import metadataJson from './metadata.json';
|
||||
import type { Category, NetworkData, Protocol, Software } from './types';
|
||||
|
||||
export const softwares = softwareJson as Software[];
|
||||
export const protocols = protocolsJson as Protocol[];
|
||||
export const categories = categoriesJson as Category[];
|
||||
export const networkData = networkJson as NetworkData;
|
||||
export const metadata = metadataJson;
|
||||
|
||||
export const categoryById: ReadonlyMap<string, Category> = new Map(categories.map((c) => [c.id, c]));
|
||||
|
||||
export const softwareById: ReadonlyMap<string, Software> = new Map(softwares.map((s) => [s.id, s]));
|
||||
|
||||
/** Ordre de tri des états d'activité (Actif d'abord). */
|
||||
export const ETAT_ORDER: readonly Software['etat'][] = [
|
||||
'Actif',
|
||||
'Maintenance',
|
||||
'Experimental',
|
||||
'Historique'
|
||||
];
|
||||
|
||||
export const ETATS: readonly Software['etat'][] = ETAT_ORDER;
|
||||
|
||||
export const COMPATIBILITES_AP: readonly Software['compatibiliteAP'][] = [
|
||||
'Pleine',
|
||||
'Partielle',
|
||||
'Extension',
|
||||
'Aucune',
|
||||
'En développement'
|
||||
];
|
||||
|
||||
export function softwaresByCategory(categoryId: string): Software[] {
|
||||
return softwares.filter((s) => s.category === categoryId);
|
||||
}
|
||||
|
||||
export const stats = {
|
||||
totalSoftwares: softwares.length,
|
||||
totalProtocols: protocols.length,
|
||||
totalCategories: categories.length,
|
||||
activeSoftwares: softwares.filter((s) => s.etat === 'Actif').length,
|
||||
networkLinks: networkData.links.length,
|
||||
yearsOfHistory: new Date().getFullYear() - 2008
|
||||
} as const;
|
||||
|
||||
export type { Category, NetworkData, NetworkLink, NetworkNode, Protocol, Software } from './types';
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Le Grand Atlas du Fédiverse",
|
||||
"version": "4.0",
|
||||
"year": 2026,
|
||||
"language": "fr",
|
||||
"licence": "CC BY-SA 4.0",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"statistics": {
|
||||
"totalSoftwares": 105,
|
||||
"totalProtocols": 12,
|
||||
"totalCategories": 14,
|
||||
"activeSoftwares": 93,
|
||||
"maintenanceSoftwares": 4,
|
||||
"experimentalSoftwares": 5,
|
||||
"historicalSoftwares": 3,
|
||||
"fullAPCompatibility": 45,
|
||||
"partialAPCompatibility": 4,
|
||||
"extensionAPCompatibility": 9
|
||||
},
|
||||
"attribution": {
|
||||
"inspiredBy": "Per Axbom (axbom.com/fediverse)",
|
||||
"originalVersion": "3.0 (January 2023)",
|
||||
"maintainers": [
|
||||
"Community"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
[
|
||||
{
|
||||
"id": "activitypub",
|
||||
"name": "ActivityPub",
|
||||
"description": "Protocole de réseautage social décentralisé recommandé par le W3C. Le standard principal du Fédiverse.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"site": "https://activitypub.rocks/",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "activitystreams",
|
||||
"name": "ActivityStreams 2.0",
|
||||
"description": "Format de données pour décrire les activités sociales. Base sur laquelle repose ActivityPub.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "webfinger",
|
||||
"name": "WebFinger",
|
||||
"description": "Protocole de découverte d'informations sur les ressources identifiées par des URI web.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 7033"
|
||||
},
|
||||
{
|
||||
"id": "httpsignatures",
|
||||
"name": "HTTP Signatures",
|
||||
"description": "Mécanisme de signature des requêtes HTTP pour l'authentification entre instances.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "IETF Draft"
|
||||
},
|
||||
{
|
||||
"id": "nodeinfo",
|
||||
"name": "NodeInfo",
|
||||
"description": "Schéma de métadonnées pour décrire les instances fédérées et leurs capacités.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "nodeinfo.diaspora.software"
|
||||
},
|
||||
{
|
||||
"id": "zot",
|
||||
"name": "Zot / Nomad",
|
||||
"description": "Protocole de communications décentralisées avec prise en charge avancée de la privacy et du nomadisme d'identité.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://zotlabs.org/"
|
||||
},
|
||||
{
|
||||
"id": "diaspora",
|
||||
"name": "Diaspora* Protocol",
|
||||
"description": "Protocole historique du réseau Diaspora*, précurseur du Fédiverse moderne.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "ostatus",
|
||||
"name": "OStatus",
|
||||
"description": "Suite de protocoles (Atom, Salmon, WebSub) utilisée par les premières plateformes fédérées.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "matrix",
|
||||
"name": "Matrix Protocol",
|
||||
"description": "Protocole ouvert pour la communication temps réel (chat, VoIP). Interopère avec ActivityPub.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://matrix.org/"
|
||||
},
|
||||
{
|
||||
"id": "xmpp",
|
||||
"name": "XMPP",
|
||||
"description": "Extensible Messaging and Presence Protocol. Standard historique de messagerie instantanée.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 6120"
|
||||
},
|
||||
{
|
||||
"id": "atproto",
|
||||
"name": "AT Protocol",
|
||||
"description": "Protocole d'identité décentralisé utilisé par Bluesky. Écosystème voisin mais distinct du Fédiverse.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://atproto.com/"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"description": "Protocole simple et ouvert pour créer des réseaux sociaux résistants à la censure.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://nostr.com/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,418 @@
|
||||
export interface TimelineEvent {
|
||||
year: number;
|
||||
month?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: 'foundation' | 'protocol' | 'software' | 'milestone' | 'ecosystem';
|
||||
softwareId?: string;
|
||||
protocolId?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const timelineEvents: TimelineEvent[] = [
|
||||
// 2008
|
||||
{
|
||||
year: 2008,
|
||||
title: 'StatusNet / Identi.ca',
|
||||
description: "Evan Prodromou lance Identi.ca basé sur StatusNet. Première plateforme de microblogging décentralisée utilisant le protocole OStatus.",
|
||||
category: 'foundation',
|
||||
color: '#6C757D',
|
||||
},
|
||||
// 2010
|
||||
{
|
||||
year: 2010,
|
||||
title: 'GNU social',
|
||||
description: "Fork de StatusNet. Devient le pionnier du réseau social fédéré avec le protocole OStatus.",
|
||||
category: 'software',
|
||||
softwareId: 'gnusocial',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2010,
|
||||
title: 'Diaspora*',
|
||||
description: "Lancement de Diaspora* après une campagne Kickstarter. Réseau social décentralisé avec son propre protocole. Précurseur du Fédiverse moderne.",
|
||||
category: 'software',
|
||||
softwareId: 'diaspora-soft',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2010,
|
||||
title: 'ownCloud',
|
||||
description: "Lancement de la plateforme de cloud auto-hébergeable. Posera plus tard les bases de Nextcloud.",
|
||||
category: 'software',
|
||||
softwareId: 'owncloud',
|
||||
color: '#2EC4B6',
|
||||
},
|
||||
// 2011
|
||||
{
|
||||
year: 2011,
|
||||
title: 'Friendica',
|
||||
description: "Plateforme sociale fédérée avec support multi-protocoles (OStatus, Diaspora*, plus tard ActivityPub).",
|
||||
category: 'software',
|
||||
softwareId: 'friendica',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2012
|
||||
{
|
||||
year: 2012,
|
||||
title: 'Hubzilla',
|
||||
description: "Plateforme de communications décentralisées avec contrôle d'accès granulaire et nomadisme d'identité via le protocole Zot.",
|
||||
category: 'software',
|
||||
softwareId: 'hubzilla',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2012,
|
||||
title: 'W3C Social Web Working Group',
|
||||
description: "Création du groupe de travail W3C sur le web social. Aboutira à la standardisation d'ActivityPub.",
|
||||
category: 'foundation',
|
||||
color: '#F72585',
|
||||
},
|
||||
// 2014
|
||||
{
|
||||
year: 2014,
|
||||
title: 'Misskey',
|
||||
description: "Lancement au Japon de Misskey, une plateforme sociale fédérée avec des fonctionnalités créatives uniques.",
|
||||
category: 'software',
|
||||
softwareId: 'misskey',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2015
|
||||
{
|
||||
year: 2015,
|
||||
title: 'PeerTube',
|
||||
description: "Framasoft lance PeerTube, une plateforme vidéo fédérée et décentralisée. Alternative libre à YouTube.",
|
||||
category: 'software',
|
||||
softwareId: 'peertube',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2015,
|
||||
title: 'Mastodon',
|
||||
description: "Eugen Rochko lance Mastodon. Le début de l'explosion du Fédiverse moderne. Utilise OStatus, migrera plus tard vers ActivityPub.",
|
||||
category: 'software',
|
||||
softwareId: 'mastodon',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2016
|
||||
{
|
||||
year: 2016,
|
||||
title: 'Pleroma',
|
||||
description: "Serveur ActivityPub léger et configurable. Alternative rapide et ressource-efficiente à Mastodon.",
|
||||
category: 'software',
|
||||
softwareId: 'pleroma',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2016,
|
||||
title: 'Matrix Protocol',
|
||||
description: "Standardisation du protocole Matrix pour la communication temps réel décentralisée.",
|
||||
category: 'protocol',
|
||||
color: '#4361EE',
|
||||
},
|
||||
{
|
||||
year: 2016,
|
||||
title: 'Nextcloud',
|
||||
description: "Fork de ownCloud devenu la référence du cloud auto-hébergeable. Ajoutera plus tard le support ActivityPub.",
|
||||
category: 'software',
|
||||
softwareId: 'nextcloud',
|
||||
color: '#2EC4B6',
|
||||
},
|
||||
// 2017
|
||||
{
|
||||
year: 2017,
|
||||
title: 'W3C ActivityPub — Candidate Recommendation',
|
||||
description: "ActivityPub atteint le statut de Candidate Recommendation au W3C. Le Fédiverse a désormais son standard.",
|
||||
category: 'protocol',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2017,
|
||||
title: 'PixelFed',
|
||||
description: "Lancement de PixelFed, plateforme de partage de photos fédérée. Alternative à Instagram.",
|
||||
category: 'software',
|
||||
softwareId: 'pixelfed',
|
||||
color: '#F8961E',
|
||||
},
|
||||
{
|
||||
year: 2017,
|
||||
title: 'WriteFreely',
|
||||
description: "Plateforme de blogging minimaliste et fédérée axée sur l'écriture pure.",
|
||||
category: 'software',
|
||||
softwareId: 'writefreely',
|
||||
color: '#7209B7',
|
||||
},
|
||||
// 2018
|
||||
{
|
||||
year: 2018,
|
||||
title: 'ActivityPub — W3C Recommendation',
|
||||
description: "ActivityPub devient une recommandation officielle du W3C. Le standard est désormais finalisé.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'BookWyrm',
|
||||
description: "Réseau social de lecture fédéré. Alternative libre à Goodreads.",
|
||||
category: 'software',
|
||||
softwareId: 'bookwyrm',
|
||||
color: '#E71D36',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'Plume',
|
||||
description: "Moteur de blog fédéré axé sur la collaboration et les publications longues.",
|
||||
category: 'software',
|
||||
softwareId: 'plume',
|
||||
color: '#7209B7',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'Funkwhale',
|
||||
description: "Plateforme musicale fédérée pour écouter, partager et découvrir de la musique.",
|
||||
category: 'software',
|
||||
softwareId: 'funkwhale',
|
||||
color: '#B5179E',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'microblog.pub',
|
||||
description: "Serveur ActivityPub minimaliste et auto-hébergeable pour le microblogging.",
|
||||
category: 'software',
|
||||
softwareId: 'microblogpub',
|
||||
color: '#F9C74F',
|
||||
},
|
||||
// 2019
|
||||
{
|
||||
year: 2019,
|
||||
title: 'Lemmy',
|
||||
description: "Agrégateur de liens et forum fédéré. Alternative à Reddit.",
|
||||
category: 'software',
|
||||
softwareId: 'lemmy',
|
||||
color: '#3A0CA3',
|
||||
},
|
||||
{
|
||||
year: 2019,
|
||||
title: 'Mobilizon',
|
||||
description: "Plateforme d'événements fédérée par Framasoft. Alternative à Facebook Events.",
|
||||
category: 'software',
|
||||
softwareId: 'mobilizon',
|
||||
color: '#4361EE',
|
||||
},
|
||||
{
|
||||
year: 2019,
|
||||
title: 'WordPress + ActivityPub',
|
||||
description: "Automattic publie le plugin ActivityPub pour WordPress. Le plus grand CMS du monde rejoint le Fédiverse.",
|
||||
category: 'software',
|
||||
softwareId: 'wordpress-ap',
|
||||
color: '#7209B7',
|
||||
},
|
||||
{
|
||||
year: 2019,
|
||||
title: 'Nostr Protocol',
|
||||
description: "Publication du protocole Nostr par fiatjaf. Une approche radicalement différente : pas de serveurs, pas d'instances, juste des relays et des clés.",
|
||||
category: 'protocol',
|
||||
color: '#F7931A',
|
||||
},
|
||||
// 2020
|
||||
{
|
||||
year: 2020,
|
||||
title: 'Owncast',
|
||||
description: "Serveur de streaming live auto-hébergeable. Alternative à Twitch.",
|
||||
category: 'software',
|
||||
softwareId: 'owncast',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2020,
|
||||
title: 'Bonfire',
|
||||
description: "Réseau social modulaire axé sur la gouvernance communautaire et la coordination sociale.",
|
||||
category: 'software',
|
||||
softwareId: 'bonfire',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2020,
|
||||
title: 'Castopod',
|
||||
description: "Plateforme de podcasting fédérée avec ActivityPub. Alternative à Spotify/Apple Podcasts.",
|
||||
category: 'software',
|
||||
softwareId: 'castopod',
|
||||
color: '#B5179E',
|
||||
},
|
||||
{
|
||||
year: 2020,
|
||||
title: 'SimpleX Chat',
|
||||
description: "Lancement de SimpleX Chat, une messagerie décentralisée de nouvelle génération sans identifiants utilisateur.",
|
||||
category: 'software',
|
||||
softwareId: 'simplexchat',
|
||||
color: '#06D6A0',
|
||||
},
|
||||
// 2021
|
||||
{
|
||||
year: 2021,
|
||||
title: 'Twitter → Mastodon',
|
||||
description: "Vague massive de migrations de Twitter vers Mastodon après l'acquisition par Elon Musk. Le Fédiverse passe de 2M à 10M+ d'utilisateurs.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2021,
|
||||
title: 'GoToSocial',
|
||||
description: "Serveur ActivityPub léger écrit en Go. Approche minimaliste et efficace pour auto-héberger son instance.",
|
||||
category: 'software',
|
||||
softwareId: 'gotosocial',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2022
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Akkoma',
|
||||
description: "Fork de Pleroma avec des fonctionnalités enrichies et une personnalisation poussée.",
|
||||
category: 'software',
|
||||
softwareId: 'akkoma',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Mbin',
|
||||
description: "Successeur de kbin, agrégateur de liens fédéré avec améliorations significatives.",
|
||||
category: 'software',
|
||||
softwareId: 'mbin',
|
||||
color: '#3A0CA3',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Gancio',
|
||||
description: "Agrégateur d'événements fédéré auto-hébergeable.",
|
||||
category: 'software',
|
||||
softwareId: 'gancio',
|
||||
color: '#4361EE',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Forgejo',
|
||||
description: "Forge logicielle libre forkée de Gitea. Le GitHub libre se fédère.",
|
||||
category: 'software',
|
||||
softwareId: 'forgejo',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Mitra',
|
||||
description: "Serveur ActivityPub en Rust avec support des monnaies complémentaires et des économies locales.",
|
||||
category: 'software',
|
||||
softwareId: 'mitra',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Streams',
|
||||
description: "Fork de Hubzilla axé sur la privacy et le contrôle granulaire des permissions.",
|
||||
category: 'software',
|
||||
softwareId: 'streams',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2023
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Threads → ActivityPub',
|
||||
description: "Meta annonce que Threads intégrera ActivityPub. Le géant des réseaux sociaux rejoint le protocole fédéré.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Sharkey',
|
||||
description: "Fork de Misskey avec améliorations de performance et nouvelles fonctionnalités.",
|
||||
category: 'software',
|
||||
softwareId: 'sharkey',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Iceshrimp',
|
||||
description: "Fork de Misskey optimisé pour la performance et la stabilité.",
|
||||
category: 'software',
|
||||
softwareId: 'iceshrimp',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'NodeBB + ActivityPub',
|
||||
description: "NodeBB ajoute le support ActivityPub. Les forums classiques rejoignent le Fédiverse.",
|
||||
category: 'software',
|
||||
softwareId: 'nodebb-ap',
|
||||
color: '#3A0CA3',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Ghost + ActivityPub',
|
||||
description: "Ghost intègre ActivityPub. Le CMS de blogging professionnel devient fédéré.",
|
||||
category: 'software',
|
||||
softwareId: 'ghost',
|
||||
color: '#7209B7',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Bluesky ouvre au public',
|
||||
description: "Bluesky (AT Protocol) ouvre ses portes. L'écosystème des réseaux décentralisés gagne en visibilité.",
|
||||
category: 'ecosystem',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2024
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Loops',
|
||||
description: "Plateforme de vidéos courtes fédérée par Mozilla. Alternative à TikTok/Instagram Reels.",
|
||||
category: 'software',
|
||||
softwareId: 'loops',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Fedify',
|
||||
description: "Framework TypeScript/Deno pour construire des serveurs ActivityPub. Simplifie le développement fédéré.",
|
||||
category: 'software',
|
||||
softwareId: 'fedify',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Vernissage',
|
||||
description: "Application de galerie photo fédérée pour le Fédiverse.",
|
||||
category: 'software',
|
||||
softwareId: 'vernissage',
|
||||
color: '#F8961E',
|
||||
},
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Nostr — Croissance explosive',
|
||||
description: "Nostr atteint des millions d'utilisateurs. Damus, Amethyst, Primal et d'autres clients dominent. Bitcoin Lightning s'intègre pour les zaps.",
|
||||
category: 'ecosystem',
|
||||
color: '#F7931A',
|
||||
},
|
||||
// 2025
|
||||
{
|
||||
year: 2025,
|
||||
title: 'Interconnexion Nostr ↔ ActivityPub',
|
||||
description: "Les premiers ponts bidirectionnels entre Nostr et ActivityPub voient le jour. Les deux écosystèmes communiquent.",
|
||||
category: 'milestone',
|
||||
color: '#F7931A',
|
||||
},
|
||||
{
|
||||
year: 2025,
|
||||
title: 'SimpleX Chat — v6.0',
|
||||
description: "SimpleX atteint la v6.0 avec des groupes chiffrés de grande taille et des appels vidéo. Alternative mature à Signal et WhatsApp.",
|
||||
category: 'software',
|
||||
softwareId: 'simplexchat',
|
||||
color: '#06D6A0',
|
||||
},
|
||||
// 2026
|
||||
{
|
||||
year: 2026,
|
||||
title: 'Le Grand Atlas du Fédiverse v4.0',
|
||||
description: "Publication de cette cartographie complète recensant 55+ logiciels, 12 protocoles et 18 ans d'histoire du réseau fédéré.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface Software {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
protocols: string[];
|
||||
licence: string;
|
||||
language: string;
|
||||
site: string;
|
||||
github: string;
|
||||
etat: 'Actif' | 'Maintenance' | 'Experimental' | 'Historique';
|
||||
compatibiliteAP: 'Pleine' | 'Partielle' | 'Extension' | 'Aucune' | 'En développement';
|
||||
version?: string;
|
||||
createdYear?: number;
|
||||
}
|
||||
|
||||
export interface Protocol {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'fediverse' | 'connexe' | 'voisin';
|
||||
statut: 'Actif' | 'Historique' | 'En développement';
|
||||
site?: string;
|
||||
rfc?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
group: number;
|
||||
val: number;
|
||||
description: string;
|
||||
category: string;
|
||||
color: string;
|
||||
etat: string;
|
||||
compatibiliteAP: string;
|
||||
licence: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
z?: number;
|
||||
vx?: number;
|
||||
vy?: number;
|
||||
vz?: number;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
fz?: number | null;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string | NetworkNode;
|
||||
target: string | NetworkNode;
|
||||
type: 'strong' | 'weak';
|
||||
}
|
||||
|
||||
export interface NetworkData {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Dictionnaire français — locale de référence.
|
||||
Structure de clés partagée avec gcf.ts (fallback automatique vers fr). */
|
||||
|
||||
export const fr = {
|
||||
a11y: {
|
||||
skipToContent: 'Aller au contenu principal'
|
||||
},
|
||||
nav: {
|
||||
map: 'Carte',
|
||||
protocols: 'Protocoles',
|
||||
timeline: 'Chronologie',
|
||||
catalog: 'Logiciels',
|
||||
nostr: 'Nostr',
|
||||
simplex: 'SimpleX',
|
||||
main: 'Navigation principale',
|
||||
openMenu: 'Ouvrir le menu',
|
||||
closeMenu: 'Fermer le menu'
|
||||
},
|
||||
header: {
|
||||
home: "Le Grand Atlas du Fédiverse — retour à l'accueil",
|
||||
backToAtlas: "Retour à l'Atlas",
|
||||
install: "Installer l'application",
|
||||
installed: 'Application installée'
|
||||
},
|
||||
offline: {
|
||||
indicator: 'Hors connexion — vous naviguez sur la copie locale'
|
||||
},
|
||||
common: {
|
||||
close: 'Fermer',
|
||||
loading: 'Chargement…',
|
||||
search: 'Rechercher',
|
||||
clear: 'Effacer',
|
||||
resetFilters: 'Réinitialiser les filtres',
|
||||
emptyResults: 'Aucun résultat pour ces critères',
|
||||
officialSite: 'Site officiel',
|
||||
sourceCode: 'Code source',
|
||||
backToTop: 'Retour en haut de page'
|
||||
},
|
||||
catalog: {
|
||||
upToDate: 'Vous êtes à jour',
|
||||
upToDateCreole: 'ou pa manké ayen',
|
||||
results: '{count} résultat(s)'
|
||||
},
|
||||
shortcuts: {
|
||||
title: 'Raccourcis clavier',
|
||||
open: 'Afficher les raccourcis clavier',
|
||||
search: 'Aller à la recherche du catalogue',
|
||||
help: 'Afficher cette aide',
|
||||
next: 'Logiciel suivant dans le catalogue',
|
||||
prev: 'Logiciel précédent dans le catalogue',
|
||||
close: 'Fermer la fenêtre'
|
||||
},
|
||||
footer: {
|
||||
baseline:
|
||||
'Une cartographie interactive et communautaire du réseau fédéré décentralisé. Maintenue à jour par et pour la communauté francophone.',
|
||||
madeIn: 'Fait avec ❤ en Guadeloupe',
|
||||
project: 'Projet',
|
||||
about: 'À propos',
|
||||
license: 'Licence CC BY-SA',
|
||||
source: 'Code source',
|
||||
api: 'Données (JSON)',
|
||||
resources: 'Ressources',
|
||||
community: 'Communauté',
|
||||
rights: 'Le Grand Atlas du Fédiverse © 2026 — Sous licence CC BY-SA 4.0',
|
||||
credits:
|
||||
'Inspiré par Per Axbom (axbom.com/fediverse) · Données mises à jour régulièrement par la communauté',
|
||||
localeLabel: 'Langue de l’interface'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/* Dictionnaire créole guadeloupéen (gcf).
|
||||
Traduction progressive : les clés absentes retombent sur le français. */
|
||||
|
||||
import type { fr } from './fr';
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
||||
};
|
||||
|
||||
export const gcf: DeepPartial<typeof fr> = {
|
||||
a11y: {
|
||||
skipToContent: 'Alé o kontni prensipal'
|
||||
},
|
||||
nav: {
|
||||
map: 'Kat',
|
||||
protocols: 'Pwotokòl',
|
||||
timeline: 'Kronoloji',
|
||||
catalog: 'Lojisyèl',
|
||||
nostr: 'Nostr',
|
||||
simplex: 'SimpleX',
|
||||
main: 'Navigasyon prensipal',
|
||||
openMenu: 'Ouvri meni an',
|
||||
closeMenu: 'Fèmé meni an'
|
||||
},
|
||||
header: {
|
||||
home: 'Gran Atlas Fédiverse — tounen aksèy',
|
||||
backToAtlas: 'Tounen an Atlas la',
|
||||
install: 'Enstalé aplikasyon an',
|
||||
installed: 'Aplikasyon enstalé'
|
||||
},
|
||||
offline: {
|
||||
indicator: 'San kònèksyon — w ap gadé kopi lokal la'
|
||||
},
|
||||
common: {
|
||||
close: 'Fèmé',
|
||||
loading: 'Sa ap chajé…',
|
||||
search: 'Chèchè',
|
||||
clear: 'Éfasé',
|
||||
resetFilters: 'Rémèt filtr yo a zéwo',
|
||||
emptyResults: 'Pa ni pyès rézilta pou kritè sa-yo',
|
||||
officialSite: 'Sit ofisyèl',
|
||||
sourceCode: 'Kòd sous',
|
||||
backToTop: 'Tounen anlè paj la'
|
||||
},
|
||||
catalog: {
|
||||
upToDate: 'Ou pa manké ayen',
|
||||
upToDateCreole: 'vous êtes à jour',
|
||||
results: '{count} rézilta'
|
||||
},
|
||||
shortcuts: {
|
||||
title: 'Rakoursi klavyé',
|
||||
open: 'Afiché rakoursi klavyé yo',
|
||||
search: 'Alé ba rachech katalòg la',
|
||||
help: 'Afiché èd sa a',
|
||||
next: 'Lojisyèl swivan an katalòg la',
|
||||
prev: 'Lojisyèl avan an katalòg la',
|
||||
close: 'Fèmé fenèt la'
|
||||
},
|
||||
footer: {
|
||||
baseline:
|
||||
'Yon kat entèaktif é kominotèr di rézo fédéré désantralizé a. Kominoté frankofonn ka tjouwen li ajou.',
|
||||
madeIn: 'Fè ké lanmou an Gwadloup',
|
||||
project: 'Pwojè',
|
||||
about: 'Konsènan',
|
||||
license: 'Lisans CC BY-SA',
|
||||
source: 'Kòd sous',
|
||||
api: 'Done (JSON)',
|
||||
resources: 'Résous',
|
||||
community: 'Kominoté',
|
||||
rights: 'Gran Atlas Fédiverse © 2026 — Anba lisans CC BY-SA 4.0',
|
||||
credits:
|
||||
'Enspiré pa Per Axbom (axbom.com/fediverse) · Done yo ka mèt ajou souvan pa kominoté a',
|
||||
localeLabel: 'Lang entèfas la'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/* i18n réactif (runes Svelte 5) — préparation bilinguisme FR / créole.
|
||||
Usage dans un composant : `import { t } from '$lib/i18n/i18n.svelte';`
|
||||
puis `{t('nav.map')}` dans le template (réactif au changement de locale). */
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { fr } from './fr';
|
||||
import { gcf } from './gcf';
|
||||
|
||||
export const LOCALES = [
|
||||
{ id: 'fr', label: 'Français' },
|
||||
{ id: 'gcf', label: 'Kréyòl' }
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof LOCALES)[number]['id'];
|
||||
|
||||
const STORAGE_KEY = 'atlas-locale';
|
||||
|
||||
const dicts: Record<Locale, unknown> = { fr, gcf };
|
||||
|
||||
let locale = $state<Locale>('fr');
|
||||
|
||||
function lookup(dict: unknown, path: string): string | undefined {
|
||||
let node: unknown = dict;
|
||||
for (const part of path.split('.')) {
|
||||
if (node === null || typeof node !== 'object') return undefined;
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof node === 'string' ? node : undefined;
|
||||
}
|
||||
|
||||
/** Traduit une clé (ex. 'nav.map'). Retombe sur le français si absente.
|
||||
Supporte l'interpolation `{name}` via params. */
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
// La lecture de `locale` rend l'appel réactif dans les templates Svelte 5.
|
||||
const raw = lookup(dicts[locale], key) ?? lookup(fr, key) ?? key;
|
||||
if (!params) return raw;
|
||||
return raw.replaceAll(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`));
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return locale;
|
||||
}
|
||||
|
||||
export function setLocale(next: Locale): void {
|
||||
locale = next;
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** À appeler une fois côté client (layout racine). */
|
||||
export function initLocale(): void {
|
||||
if (!browser) return;
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'fr' || saved === 'gcf') {
|
||||
locale = saved;
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Garde commune : ne pas intercepter les raccourcis pendant la saisie. */
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return (
|
||||
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* État PWA partagé (runes) — capture beforeinstallprompt, détection installée. */
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
let deferredPrompt = $state<BeforeInstallPromptEvent | null>(null);
|
||||
let installed = $state(false);
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/** À appeler une fois côté client (layout racine). */
|
||||
export function initPwa(): void {
|
||||
if (!browser || initialized) return;
|
||||
initialized = true;
|
||||
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
installed = true;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event as BeforeInstallPromptEvent;
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function canInstall(): boolean {
|
||||
return deferredPrompt !== null;
|
||||
}
|
||||
|
||||
export function isInstalled(): boolean {
|
||||
return installed;
|
||||
}
|
||||
|
||||
/** Déclenche le prompt d'installation natif. Retourne true si accepté. */
|
||||
export async function promptInstall(): Promise<boolean> {
|
||||
if (!deferredPrompt) return false;
|
||||
const promptEvent = deferredPrompt;
|
||||
deferredPrompt = null;
|
||||
await promptEvent.prompt();
|
||||
const { outcome } = await promptEvent.userChoice;
|
||||
return outcome === 'accepted';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
|
||||
import ShortcutsHelp from '$lib/components/ShortcutsHelp.svelte';
|
||||
import { initLocale, t } from '$lib/i18n/i18n.svelte';
|
||||
import { initPwa } from '$lib/pwa.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
// Marqueur de démarrage pour le script de diagnostic de app.html
|
||||
document.documentElement.dataset.js = 'ok';
|
||||
initLocale();
|
||||
initPwa();
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main">{t('a11y.skipToContent')}</a>
|
||||
<Header />
|
||||
<main id="main" tabindex="-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
<OfflineIndicator />
|
||||
<ShortcutsHelp />
|
||||
@@ -0,0 +1,6 @@
|
||||
export const prerender = true;
|
||||
|
||||
// Hébergement statique (nginx YunoHost) : chaque page est générée dans son
|
||||
// dossier (nostr/index.html) — structure la plus sûre pour du statique,
|
||||
// sans dépendre de règles de réécriture.
|
||||
export const trailingSlash = 'always';
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import HeroSection from '$lib/components/HeroSection.svelte';
|
||||
import NetworkGraph from '$lib/components/NetworkGraph.svelte';
|
||||
import ProtocolSection from '$lib/components/ProtocolSection.svelte';
|
||||
import TimelineSection from '$lib/components/TimelineSection.svelte';
|
||||
import SoftwareCatalog from '$lib/components/SoftwareCatalog.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Le Grand Atlas du Fédiverse — 2026</title>
|
||||
</svelte:head>
|
||||
|
||||
<HeroSection />
|
||||
<NetworkGraph />
|
||||
<ProtocolSection />
|
||||
<TimelineSection />
|
||||
<SoftwareCatalog />
|
||||
@@ -0,0 +1,702 @@
|
||||
<script lang="ts">
|
||||
import { softwares } from '$lib/data';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import Icon from '$lib/components/pages/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/pages/Icon.svelte';
|
||||
import FeatureGrid from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import type { FeatureItem } from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import StepsGrid from '$lib/components/pages/StepsGrid.svelte';
|
||||
import type { StepItem } from '$lib/components/pages/StepsGrid.svelte';
|
||||
import CompareTable from '$lib/components/pages/CompareTable.svelte';
|
||||
import type { CompareRow } from '$lib/components/pages/CompareTable.svelte';
|
||||
|
||||
const clients = softwares.filter((s) => s.category === 'nostr');
|
||||
|
||||
let openId = $state<string | null>(null);
|
||||
|
||||
function toggle(id: string): void {
|
||||
openId = openId === id ? null : id;
|
||||
}
|
||||
|
||||
const steps: StepItem[] = [
|
||||
{
|
||||
step: '01',
|
||||
icon: 'key',
|
||||
title: 'Générez vos clés',
|
||||
description:
|
||||
"Votre client Nostr crée une paire de clés (npub + nsec). Pas d'inscription, pas d'email. Vos clés SONT votre identité."
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
icon: 'server',
|
||||
title: 'Connectez des relays',
|
||||
description:
|
||||
'Choisissez des relays publics ou hébergez le vôtre. Vos messages sont publiés sur tous les relays que vous sélectionnez.'
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
icon: 'message',
|
||||
title: 'Communiquez',
|
||||
description:
|
||||
"Publiez des notes, suivez d'autres utilisateurs, envoyez des messages privés chiffrés. Tout fonctionne sans serveur central."
|
||||
}
|
||||
];
|
||||
|
||||
const features: FeatureItem[] = [
|
||||
{
|
||||
icon: 'key',
|
||||
title: 'Clés cryptographiques',
|
||||
description:
|
||||
"Pas de compte, pas d'email, pas de mot de passe. Votre identité est une paire de clés (npub/nsec). Vous la contrôlez entièrement."
|
||||
},
|
||||
{
|
||||
icon: 'server',
|
||||
title: "Relays, pas d'instances",
|
||||
description:
|
||||
'Pas de serveurs à administrer. Les relays sont de simples relais de messages. Vous en choisissez plusieurs, vous pouvez en changer à tout moment.'
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Chiffrement de bout en bout',
|
||||
description:
|
||||
'Les messages DM sont chiffrés côté client avec les clés publiques du destinataire. Même les relays ne peuvent pas lire vos messages.'
|
||||
},
|
||||
{
|
||||
icon: 'bitcoin',
|
||||
title: 'Lightning Zaps',
|
||||
description:
|
||||
'Intégration native avec Bitcoin Lightning Network. Envoyez des satoshis (zaps) en récompense de contenus. Économie circulaire intégrée.'
|
||||
},
|
||||
{
|
||||
icon: 'radio',
|
||||
title: 'Censure-résistant',
|
||||
description:
|
||||
'Pas de point central de contrôle. Vos messages existent sur de multiples relays simultanément. Impossible de vous bannir du réseau.'
|
||||
},
|
||||
{
|
||||
icon: 'globe',
|
||||
title: 'Interopérabilité totale',
|
||||
description:
|
||||
'Tous les clients Nostr peuvent lire et publier sur tous les relays. Pas de fragmentation comme entre instances ActivityPub.'
|
||||
}
|
||||
];
|
||||
|
||||
const comparison: CompareRow[] = [
|
||||
{ aspect: 'Architecture', values: ['Instances fédérées (serveurs)', 'Relays décentralisés (pas de serveurs)'] },
|
||||
{ aspect: 'Identité', values: ['@utilisateur@instance.social', 'Clé publique (npub1...)'] },
|
||||
{ aspect: 'Chiffrement', values: ['TLS + HTTP Signatures (serveur)', 'Chiffrement client-side (NIP-04)'] },
|
||||
{ aspect: 'Résilience', values: ['Instance = point de défaillance', 'Multi-relays, pas de SPOF'] },
|
||||
{ aspect: 'Découverte', values: ['WebFinger + annuaires', 'Relays + moteurs de recherche'] },
|
||||
{ aspect: 'Médias', values: ["Stockage sur l'instance", 'Blossom / NIP-96 (décentralisé)'] },
|
||||
{ aspect: 'Monétisation', values: ['Donations, sponsoring', 'Lightning Zaps intégrés'] },
|
||||
{ aspect: 'Modération', values: ["Administrateurs d'instance", 'Client-side, choix des relays'] }
|
||||
];
|
||||
|
||||
const simplexPoints: { icon: IconName; title: string; description: string }[] = [
|
||||
{
|
||||
icon: 'user-x',
|
||||
title: "Pas d'identifiants",
|
||||
description:
|
||||
"Aucun numéro de téléphone, email ou nom d'utilisateur. Vos contacts ne se connaissent pas entre eux."
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Chiffrement E2E',
|
||||
description:
|
||||
'Chiffrement de bout en bout par défaut. Même les serveurs SimpleX ne peuvent pas voir vos messages.'
|
||||
},
|
||||
{
|
||||
icon: 'globe',
|
||||
title: 'Décentralisé',
|
||||
description:
|
||||
'Utilisez les serveurs publics ou hébergez les vôtres. Les utilisateurs peuvent utiliser différents serveurs.'
|
||||
},
|
||||
{
|
||||
icon: 'github',
|
||||
title: 'Open source',
|
||||
description:
|
||||
'Code open source audité. Client en Haskell, applications iOS, Android et desktop.'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Nostr — Le Grand Atlas du Fédiverse</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Nostr, protocole ouvert et résistant à la censure : clés cryptographiques, relays, 18 clients à découvrir et comparaison avec ActivityPub."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page" style:--page-accent="var(--accent-3)">
|
||||
<!-- Hero -->
|
||||
<section class="hero" aria-labelledby="nostr-title">
|
||||
<div class="center stack hero-inner">
|
||||
<p class="badge">
|
||||
<Icon name="zap" size={16} />
|
||||
<span>Écosystème voisin</span>
|
||||
</p>
|
||||
<h1 id="nostr-title">
|
||||
Nostr :
|
||||
<span class="gradient-text">Notes and Other Stuff<br />Transmitted by Relays</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Un protocole ouvert, simple et résistant à la censure pour créer des réseaux sociaux
|
||||
décentralisés. Pas d'instances, pas de serveurs — juste des <strong>clés</strong> et des
|
||||
<strong>relays</strong>.
|
||||
</p>
|
||||
<div class="cluster cta">
|
||||
<a class="btn btn-primary" href="https://nostr.com/" target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="external" size={16} />
|
||||
Découvrir Nostr
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost"
|
||||
href="https://github.com/nostr-protocol/nips"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={16} />
|
||||
NIPs (specs)
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comment ça marche -->
|
||||
<section class="section section-alt" aria-labelledby="how-title">
|
||||
<div class="center stack">
|
||||
<h2 id="how-title" class="section-title">Comment ça <span class="accent-text">marche</span> ?</h2>
|
||||
<StepsGrid items={steps} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pourquoi Nostr -->
|
||||
<section class="section" aria-labelledby="why-title">
|
||||
<div class="center stack">
|
||||
<h2 id="why-title" class="section-title">Pourquoi <span class="accent-text">Nostr</span> ?</h2>
|
||||
<FeatureGrid items={features} min="19rem" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clients Nostr -->
|
||||
<section class="section section-alt" aria-labelledby="clients-title">
|
||||
<div class="center stack">
|
||||
<h2 id="clients-title" class="section-title">
|
||||
Les clients <span class="accent-text">Nostr</span>
|
||||
</h2>
|
||||
<p class="section-lede">
|
||||
Écosystème riche de clients web, mobiles et desktop. Choisissez celui qui correspond à vos
|
||||
besoins.
|
||||
</p>
|
||||
|
||||
<div class="clients-cq">
|
||||
<ul class="grid clients-grid" role="list" style:--grid-min="19rem">
|
||||
{#each clients as sw (sw.id)}
|
||||
{@const open = openId === sw.id}
|
||||
<li class="client-card" class:open>
|
||||
<h3 class="client-heading">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="client-details-{sw.id}"
|
||||
id="client-btn-{sw.id}"
|
||||
onclick={() => toggle(sw.id)}
|
||||
>
|
||||
<span class="client-initial" aria-hidden="true">{sw.name.charAt(0)}</span>
|
||||
<span class="client-names">
|
||||
<span class="client-name">{sw.name}</span>
|
||||
<span class="client-lang">{sw.language}</span>
|
||||
</span>
|
||||
<span class="client-chevron" class:open>
|
||||
<Icon name="chevron" size={18} />
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<p class="client-desc">{sw.description}</p>
|
||||
<p class="client-etat"><span class="etat-badge">{sw.etat}</span></p>
|
||||
{#if open}
|
||||
<div
|
||||
class="client-details"
|
||||
id="client-details-{sw.id}"
|
||||
role="region"
|
||||
aria-labelledby="client-btn-{sw.id}"
|
||||
>
|
||||
<dl class="client-facts">
|
||||
<div>
|
||||
<dt>Licence</dt>
|
||||
<dd>{sw.licence}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Créé en</dt>
|
||||
<dd>{sw.createdYear ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="cluster">
|
||||
<a
|
||||
class="btn btn-primary btn-small"
|
||||
href={sw.site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="external" size={14} />
|
||||
{t('common.officialSite')}
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost btn-small"
|
||||
href={sw.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={14} />
|
||||
{t('common.sourceCode')}
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tableau comparatif -->
|
||||
<section class="section" aria-labelledby="compare-title">
|
||||
<div class="center stack" style:--center-max="60rem">
|
||||
<h2 id="compare-title" class="section-title">
|
||||
ActivityPub <span class="vs">vs</span> <span class="accent-text">Nostr</span>
|
||||
</h2>
|
||||
<CompareTable
|
||||
caption="Deux approches différentes de la décentralisation sociale. L'une n'exclut pas l'autre — des ponts sont en construction."
|
||||
columns={['ActivityPub (Fédiverse)', 'Nostr']}
|
||||
rows={comparison}
|
||||
highlight={1}
|
||||
ariaLabel="Tableau comparatif ActivityPub et Nostr — faire défiler horizontalement si nécessaire"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Encart SimpleX Chat -->
|
||||
<section class="section section-deep" aria-labelledby="simplex-title">
|
||||
<div class="center" style:--center-max="60rem">
|
||||
<article class="simplex-panel stack">
|
||||
<header class="simplex-head">
|
||||
<span class="simplex-icon"><Icon name="shield" size={24} /></span>
|
||||
<div>
|
||||
<p class="kicker">Alternative / Complément</p>
|
||||
<h2 id="simplex-title">SimpleX Chat</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
<strong>SimpleX Chat</strong> est une messagerie décentralisée de nouvelle génération qui prend
|
||||
une approche radicalement différente :
|
||||
<strong>pas d'identifiants utilisateur du tout</strong>. Contrairement au Fédiverse (où vous
|
||||
avez @utilisateur@instance) ou à Nostr (où vous avez une clé publique), SimpleX n'a aucun
|
||||
identifiant global. Chaque contact est un lien unique, chaque appareil est indépendant.
|
||||
</p>
|
||||
|
||||
<ul class="grid simplex-points" role="list" style:--grid-min="16rem">
|
||||
{#each simplexPoints as point (point.title)}
|
||||
<li class="simplex-point">
|
||||
<h3>
|
||||
<Icon name={point.icon} size={16} />
|
||||
{point.title}
|
||||
</h3>
|
||||
<p>{point.description}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="cluster">
|
||||
<a
|
||||
class="btn btn-simplex"
|
||||
href="https://simplex.chat/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="external" size={16} />
|
||||
Site officiel
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost"
|
||||
href="https://github.com/simplex-chat/simplex-chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={16} />
|
||||
Code source
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<p class="simplex-badges">
|
||||
<span class="mono">AGPL-3.0</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="mono">Haskell</span>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
--page-accent: var(--accent-3);
|
||||
--page-accent-text: var(--accent-3-text);
|
||||
--page-accent-strong: var(--accent-3-strong);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
|
||||
.hero {
|
||||
padding-block-start: calc(var(--header-h) + var(--space-6));
|
||||
padding-block-end: var(--space-6);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
--stack-gap: var(--space-4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid color-mix(in oklch, var(--page-accent) 40%, transparent);
|
||||
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-inline-size: 22ch;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent-3-text), var(--accent-2-text));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-2);
|
||||
max-inline-size: 58ch;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.cta {
|
||||
justify-content: center;
|
||||
--cluster-gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
.section-alt {
|
||||
background: var(--ocean);
|
||||
}
|
||||
|
||||
.section-deep {
|
||||
background: var(--abyss);
|
||||
}
|
||||
|
||||
.section > .center {
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.vs {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.section-lede {
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ---------- Boutons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-small);
|
||||
text-decoration: none;
|
||||
transition: transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--page-accent-strong);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
/* ---------- Accordéon clients ---------- */
|
||||
|
||||
.clients-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.clients-grid {
|
||||
--grid-min: 100%;
|
||||
}
|
||||
|
||||
@container (min-width: 40rem) {
|
||||
.clients-grid {
|
||||
--grid-min: 19rem;
|
||||
}
|
||||
}
|
||||
|
||||
.client-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
transition: border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.client-card.open {
|
||||
border-color: color-mix(in oklch, var(--page-accent) 50%, transparent);
|
||||
}
|
||||
|
||||
.client-heading {
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.client-heading button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.client-initial {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.5rem;
|
||||
block-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-1);
|
||||
background: color-mix(in oklch, var(--page-accent) 16%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.client-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.client-heading button:hover .client-name,
|
||||
.client-card.open .client-name {
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.client-lang {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.client-chevron {
|
||||
margin-inline-start: auto;
|
||||
color: var(--muted);
|
||||
transition: transform var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.client-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.client-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.client-etat {
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.etat-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.client-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-block-start: var(--space-3);
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.client-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.client-facts > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.client-facts dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.client-facts dd {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Encart SimpleX ---------- */
|
||||
|
||||
.simplex-panel {
|
||||
--stack-gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-3);
|
||||
}
|
||||
|
||||
.simplex-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.simplex-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--accent-4-text);
|
||||
background: color-mix(in oklch, var(--accent-4) 14%, transparent);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.simplex-panel p {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.simplex-panel p strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.simplex-point {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid color-mix(in oklch, var(--accent-4) 18%, transparent);
|
||||
background: color-mix(in oklch, var(--accent-4) 8%, transparent);
|
||||
}
|
||||
|
||||
.simplex-point h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-1);
|
||||
color: var(--accent-4-text);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.simplex-point p {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.btn-simplex {
|
||||
background: var(--accent-4-strong);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.simplex-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,497 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/pages/Icon.svelte';
|
||||
import FeatureGrid from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import type { FeatureItem } from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import StepsGrid from '$lib/components/pages/StepsGrid.svelte';
|
||||
import type { StepItem } from '$lib/components/pages/StepsGrid.svelte';
|
||||
import CompareTable from '$lib/components/pages/CompareTable.svelte';
|
||||
import type { CompareRow } from '$lib/components/pages/CompareTable.svelte';
|
||||
import type { IconName } from '$lib/components/pages/Icon.svelte';
|
||||
|
||||
const pageStats = [
|
||||
{ value: '1M+', label: 'Téléchargements' },
|
||||
{ value: '100+', label: 'Pays' },
|
||||
{ value: 'AGPL-3.0', label: 'Licence' },
|
||||
{ value: '100%', label: 'Open source' }
|
||||
];
|
||||
|
||||
const steps: StepItem[] = [
|
||||
{
|
||||
step: '01',
|
||||
icon: 'key',
|
||||
title: "L'application crée vos clés",
|
||||
description:
|
||||
'Votre appareil génère des clés cryptographiques. Aucune information personnelle requise.'
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
icon: 'users',
|
||||
title: 'Connectez via des liens',
|
||||
description:
|
||||
"Chaque contact utilise un lien d'invitation unique et jetable. Partagez par QR code, URL ou message."
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
icon: 'message',
|
||||
title: 'Communiquez en privé',
|
||||
description:
|
||||
'Messages, groupes et appels chiffrés de bout en bout. Vos métadonnées restent privées.'
|
||||
}
|
||||
];
|
||||
|
||||
const features: FeatureItem[] = [
|
||||
{
|
||||
icon: 'user-x',
|
||||
title: 'Zéro identifiant',
|
||||
description:
|
||||
"Aucun numéro de téléphone, email, nom d'utilisateur ou mot de passe. Vos contacts ne se connaissent pas entre eux."
|
||||
},
|
||||
{
|
||||
icon: 'eye-off',
|
||||
title: 'Métadonnées minimisées',
|
||||
description:
|
||||
'SimpleX ne collecte pas vos métadonnées. Pas de serveur central qui connaît votre réseau social.'
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Chiffrement E2E de nouvelle génération',
|
||||
description:
|
||||
'Double Ratchet Algorithm (comme Signal) + chiffrement post-quantique. Même les serveurs ne peuvent pas lire vos messages.'
|
||||
},
|
||||
{
|
||||
icon: 'server-off',
|
||||
title: 'Sans serveur de métadonnées',
|
||||
description:
|
||||
"Contrairement à Signal ou WhatsApp, il n'y a pas de serveur central qui connaît qui parle avec qui."
|
||||
},
|
||||
{
|
||||
icon: 'fingerprint',
|
||||
title: 'Connexions anonymes',
|
||||
description:
|
||||
"Chaque contact utilise un lien d'invitation unique jetable. Votre identité n'est jamais révélée au serveur."
|
||||
},
|
||||
{
|
||||
icon: 'globe',
|
||||
title: 'Réseau décentralisé',
|
||||
description:
|
||||
'Utilisez les serveurs publics gratuits ou hébergez le vôtre. Aucun point unique de défaillance.'
|
||||
},
|
||||
{
|
||||
icon: 'message',
|
||||
title: 'Groupes de grande taille',
|
||||
description:
|
||||
'Groupes chiffrés de plusieurs centaines de membres avec administration décentralisée.'
|
||||
},
|
||||
{
|
||||
icon: 'phone',
|
||||
title: 'Appels vocaux et vidéo',
|
||||
description:
|
||||
'Appels chiffrés de bout en bout directement entre appareils, sans passer par un serveur.'
|
||||
}
|
||||
];
|
||||
|
||||
const comparison: CompareRow[] = [
|
||||
{
|
||||
aspect: 'Identifiants',
|
||||
values: ['Aucun', 'Numéro de téléphone', 'Numéro de téléphone', "Nom d'utilisateur"]
|
||||
},
|
||||
{
|
||||
aspect: 'Chiffrement',
|
||||
values: ['Double Ratchet + PQ', 'Double Ratchet', 'Signal Protocol', 'Olm/Megolm']
|
||||
},
|
||||
{
|
||||
aspect: 'Métadonnées',
|
||||
values: ['Minimisées', 'Collectées (contacts)', 'Collectées (Meta)', 'Sur le serveur']
|
||||
},
|
||||
{
|
||||
aspect: 'Serveur',
|
||||
values: ['Sans métadonnées', 'Central (Signal LLC)', 'Central (Meta)', 'Instance choisie']
|
||||
},
|
||||
{
|
||||
aspect: 'Code source',
|
||||
values: ['AGPL-3.0 (100%)', 'GPL-3.0 (client)', 'Fermé', 'Apache-2.0']
|
||||
},
|
||||
{
|
||||
aspect: 'Fédération',
|
||||
values: ['Non (intentionnel)', 'Non', 'Non', 'Oui (fédéré)']
|
||||
}
|
||||
];
|
||||
|
||||
const useCases: { icon: IconName; title: string; description: string }[] = [
|
||||
{
|
||||
icon: 'eye-off',
|
||||
title: 'Journalisme',
|
||||
description: 'Protection des sources avec des connexions anonymes et sans métadonnées.'
|
||||
},
|
||||
{
|
||||
icon: 'shield',
|
||||
title: 'Activisme',
|
||||
description: 'Communication résistante à la surveillance étatique ou corporative.'
|
||||
},
|
||||
{
|
||||
icon: 'users',
|
||||
title: 'Communautés',
|
||||
description: 'Groupes privés avec administration décentralisée et grande capacité.'
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Entreprise',
|
||||
description: 'Messagerie interne auto-hébergée avec confidentialité garantie.'
|
||||
}
|
||||
];
|
||||
|
||||
const chips = ['Messagerie privée', 'Sans métadonnées', 'Complément Fediverse', 'Chiffrement PQ'];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SimpleX Chat — Le Grand Atlas du Fédiverse</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="SimpleX Chat, la première messagerie sans identifiants d'utilisateur : chiffrement de bout en bout post-quantique, métadonnées minimisées, comparaison avec Signal, WhatsApp et Matrix."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<!-- Hero -->
|
||||
<section class="hero" aria-labelledby="simplex-title">
|
||||
<div class="center stack hero-inner">
|
||||
<p class="badge">
|
||||
<Icon name="shield" size={16} />
|
||||
<span>Messagerie de nouvelle génération</span>
|
||||
</p>
|
||||
<h1 id="simplex-title">La messagerie <span class="accent-text">sans identifiants</span></h1>
|
||||
<p class="lede">
|
||||
SimpleX Chat est la première messagerie au monde
|
||||
<strong>sans identifiants d'utilisateur</strong>. Pas de numéro de téléphone, pas d'email,
|
||||
pas de nom d'utilisateur. Uniquement des connexions anonymes avec chiffrement de bout en
|
||||
bout post-quantique.
|
||||
</p>
|
||||
<div class="cluster cta">
|
||||
<a class="btn btn-primary" href="https://simplex.chat/" target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="external" size={16} />
|
||||
Site officiel
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost"
|
||||
href="https://github.com/simplex-chat/simplex-chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={16} />
|
||||
Code source
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="grid hero-stats" role="list" style:--grid-min="9rem">
|
||||
{#each pageStats as stat (stat.label)}
|
||||
<li class="stat-card">
|
||||
<span class="stat-value">{stat.value}</span>
|
||||
<span class="stat-label">{stat.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comment ça marche -->
|
||||
<section class="section section-alt" aria-labelledby="how-title">
|
||||
<div class="center stack">
|
||||
<h2 id="how-title" class="section-title">Comment ça <span class="accent-text">marche</span> ?</h2>
|
||||
<StepsGrid items={steps} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pourquoi SimpleX -->
|
||||
<section class="section" aria-labelledby="why-title">
|
||||
<div class="center stack">
|
||||
<h2 id="why-title" class="section-title">Pourquoi <span class="accent-text">SimpleX</span> ?</h2>
|
||||
<FeatureGrid items={features} min="15rem" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tableau comparatif -->
|
||||
<section class="section section-alt" aria-labelledby="compare-title">
|
||||
<div class="center stack" style:--center-max="62rem">
|
||||
<h2 id="compare-title" class="section-title">
|
||||
SimpleX <span class="vs">vs</span> les autres
|
||||
</h2>
|
||||
<CompareTable
|
||||
caption="SimpleX se distingue des messageries centralisées comme des outils fédérés : aucun identifiant, métadonnées minimisées."
|
||||
columns={['SimpleX', 'Signal', 'WhatsApp', 'Matrix']}
|
||||
rows={comparison}
|
||||
highlight={0}
|
||||
ariaLabel="Tableau comparatif SimpleX, Signal, WhatsApp et Matrix — faire défiler horizontalement si nécessaire"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cas d'usage -->
|
||||
<section class="section" aria-labelledby="usecases-title">
|
||||
<div class="center stack">
|
||||
<h2 id="usecases-title" class="section-title">Cas d'<span class="accent-text">usage</span></h2>
|
||||
<div class="usecases-cq">
|
||||
<ul class="grid" role="list" style:--grid-min="15rem">
|
||||
{#each useCases as useCase (useCase.title)}
|
||||
<li class="usecase-card">
|
||||
<span class="usecase-icon"><Icon name={useCase.icon} size={24} /></span>
|
||||
<h3>{useCase.title}</h3>
|
||||
<p>{useCase.description}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SimpleX et le Fédiverse -->
|
||||
<section class="section section-deep" aria-labelledby="fediverse-title">
|
||||
<div class="center" style:--center-max="60rem">
|
||||
<article class="fediverse-panel stack">
|
||||
<h2 id="fediverse-title">SimpleX et le <span class="accent-text">Fédiverse</span></h2>
|
||||
<p>
|
||||
SimpleX Chat n'est pas fédéré au sens ActivityPub — et c'est
|
||||
<strong>intentionnel</strong>. L'absence de fédération est un choix de design pour
|
||||
préserver la confidentialité.
|
||||
</p>
|
||||
<p>
|
||||
Cependant, SimpleX complète parfaitement le Fédiverse : utilisez Mastodon/Pleroma pour
|
||||
votre présence sociale publique, et SimpleX pour vos communications privées sensibles.
|
||||
</p>
|
||||
<ul class="cluster chips" role="list">
|
||||
{#each chips as chip (chip)}
|
||||
<li class="chip mono">{chip}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
--page-accent: var(--accent-4);
|
||||
--page-accent-text: var(--accent-4-text);
|
||||
--page-accent-strong: var(--accent-4-strong);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
|
||||
.hero {
|
||||
padding-block-start: calc(var(--header-h) + var(--space-6));
|
||||
padding-block-end: var(--space-6);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
--stack-gap: var(--space-4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid color-mix(in oklch, var(--page-accent) 40%, transparent);
|
||||
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-inline-size: 22ch;
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-2);
|
||||
max-inline-size: 58ch;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.cta {
|
||||
justify-content: center;
|
||||
--cluster-gap: var(--space-3);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
inline-size: min(100%, 34rem);
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 700;
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
.section-alt {
|
||||
background: var(--ocean);
|
||||
}
|
||||
|
||||
.section-deep {
|
||||
background: var(--abyss);
|
||||
}
|
||||
|
||||
.section > .center {
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vs {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---------- Boutons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-small);
|
||||
text-decoration: none;
|
||||
transition: transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--page-accent-strong);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Cas d'usage ---------- */
|
||||
|
||||
.usecases-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.usecase-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.usecase-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--page-accent-text);
|
||||
background: color-mix(in oklch, var(--page-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.usecase-card h3 {
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.usecase-card p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 40rem) {
|
||||
.usecase-card {
|
||||
align-items: flex-start;
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Panel Fédiverse ---------- */
|
||||
|
||||
.fediverse-panel {
|
||||
--stack-gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-3);
|
||||
}
|
||||
|
||||
.fediverse-panel p {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.fediverse-panel p strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.chips {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.75rem;
|
||||
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Service Worker — PWA offline-first.
|
||||
Stratégies :
|
||||
- Cache-First pour les assets statiques pré-cachés (build + fichiers static)
|
||||
- Network-First pour les données JSON (/data/*) et les navigations, repli cache
|
||||
- Stale-While-Revalidate pour le reste same-origin
|
||||
|
||||
Note doctrine : l'Atlas est un site de consultation sans actions mutatives
|
||||
(aucun POST/formulaire serveur) ; il n'y a donc pas de file d'attente
|
||||
d'actions hors-ligne à implémenter — la structure est prête si besoin. */
|
||||
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { build, files, prerendered, version } from '$service-worker';
|
||||
|
||||
const worker = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const PRECACHE = `atlas-precache-${version}`;
|
||||
const RUNTIME = `atlas-runtime-${version}`;
|
||||
|
||||
// Les listes $service-worker incluent le base path (/fediverse) :
|
||||
// build = chunks Vite, files = static/, prerendered = pages HTML prérendues.
|
||||
const PRECACHE_URLS = [...build, ...files, ...prerendered];
|
||||
|
||||
worker.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(PRECACHE)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => worker.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
worker.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== PRECACHE && key !== RUNTIME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
.then(() => worker.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
async function cacheFirst(request: Request): Promise<Response> {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(RUNTIME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request: Request): Promise<Response> {
|
||||
const cache = await caches.open(RUNTIME);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const url = new URL(request.url);
|
||||
// Candidates : copie de la visite, version canonique avec slash final
|
||||
// (trailingSlash: 'always' → pages en dossiers), puis la racine du scope
|
||||
// (registration.scope inclut le base path, ex. https://o-k-i.net/fediverse/).
|
||||
const candidates = [
|
||||
request.url,
|
||||
`${url.origin}${url.pathname.replace(/\/?$/, '/')}`,
|
||||
worker.registration.scope
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const hit = await caches.match(candidate);
|
||||
if (hit) return hit;
|
||||
}
|
||||
throw new Error('Hors connexion et rien en cache');
|
||||
}
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request: Request): Promise<Response> {
|
||||
const cache = await caches.open(RUNTIME);
|
||||
const cached = await cache.match(request);
|
||||
const fetched = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached as Promise<Response>);
|
||||
return cached ?? fetched;
|
||||
}
|
||||
|
||||
worker.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// Données JSON téléchargeables : fraîcheur d'abord, repli cache.
|
||||
if (url.pathname.startsWith('/data/')) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigations : réseau d'abord (contenu frais), cache hors-ligne.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets immuables pré-cachés (JS/CSS/fonts/icônes versionnés).
|
||||
if (PRECACHE_URLS.includes(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(staleWhileRevalidate(request));
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/* base.css — HTML sémantique, focus visible.
|
||||
Les @font-face sont dans app.html (chemins %sveltekit.assets% = base /fediverse). */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: var(--measure);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-2-text);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
small,
|
||||
.small {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
/* Focus visible sur tous les éléments interactifs */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-2);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-1);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* Classe utilitaire lecteur d'écran */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
inset-block-start: var(--space-2);
|
||||
inset-inline-start: var(--space-2);
|
||||
z-index: 100;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-radius: var(--radius-1);
|
||||
transform: translateY(-200%);
|
||||
transition: transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.skip-link:focus-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Éthique du mouvement : kill-switch global */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Contraste renforcé si demandé */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--border: var(--ink);
|
||||
--muted: var(--ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* layouts.css — primitives Every Layout : stack, cluster, sidebar, center, grid */
|
||||
|
||||
@layer layouts {
|
||||
/* Stack : flux vertical à rythme régulier */
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: var(--stack-gap, var(--space-3));
|
||||
}
|
||||
|
||||
/* Cluster : groupe horizontal qui s'enroule (pills, boutons, nav) */
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--cluster-gap, var(--space-2));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Sidebar : panneau latéral + contenu fluide, replie en colonne */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sidebar-gap, var(--space-4));
|
||||
}
|
||||
|
||||
.sidebar > :first-child {
|
||||
flex-basis: var(--sidebar-width, 20rem);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sidebar > :last-child {
|
||||
flex-basis: 0;
|
||||
flex-grow: 999;
|
||||
min-inline-size: var(--sidebar-content-min, 55%);
|
||||
}
|
||||
|
||||
/* Center : centrage horizontal à mesure maximale */
|
||||
.center {
|
||||
box-sizing: content-box;
|
||||
margin-inline: auto;
|
||||
max-inline-size: var(--center-max, var(--content-max));
|
||||
padding-inline: var(--center-padding, var(--space-3));
|
||||
}
|
||||
|
||||
/* Grid : répartition auto-adaptative sans media query */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--grid-gap, var(--space-3));
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(min(100%, var(--grid-min, 18rem)), 1fr)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* reset.css — reset moderne minimal */
|
||||
|
||||
@layer reset {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* jamais de font-size en px : la taille utilisateur est respectée */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
ul[role='list'],
|
||||
ol[role='list'] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* themes/default.css — tokens sémantiques du projet (couleurs de marque, surfaces, états).
|
||||
Toutes les valeurs dérivent des tokens primitifs ou de la palette oklch du projet. */
|
||||
|
||||
@layer tokens {
|
||||
:root {
|
||||
/* Surfaces profondes (identité « grand large ») */
|
||||
--surface-3: light-dark(oklch(92% 0.015 90), oklch(24% 0.03 250));
|
||||
--abyss: light-dark(oklch(96% 0.01 90), oklch(12% 0.025 255)); /* fond le plus profond #001219 */
|
||||
--ocean: light-dark(oklch(93% 0.02 90), oklch(19% 0.045 250)); /* fond intermédiaire #012A4A */
|
||||
|
||||
/* Texte */
|
||||
--ink-soft: light-dark(oklch(35% 0.03 260), oklch(82% 0.04 235)); /* #A9D6E5 en sombre, AA en clair */
|
||||
--on-accent: oklch(99% 0.01 90);
|
||||
|
||||
/* Bordures & verre */
|
||||
--border: light-dark(oklch(86% 0.015 260), oklch(34% 0.03 250));
|
||||
--border-strong: light-dark(oklch(75% 0.02 260), oklch(45% 0.04 250));
|
||||
--glass: light-dark(oklch(99% 0.005 90 / 0.75), oklch(17% 0.03 250 / 0.72));
|
||||
--glass-border: light-dark(oklch(80% 0.02 260 / 0.6), oklch(50% 0.05 250 / 0.4));
|
||||
|
||||
/* États d'activité (doublés par du texte, jamais couleur seule) */
|
||||
--status-active: light-dark(oklch(55% 0.14 165), oklch(75% 0.16 165));
|
||||
--status-maintenance: light-dark(oklch(52% 0.12 90), oklch(80% 0.15 90));
|
||||
--status-experimental: var(--accent);
|
||||
--status-historical: var(--muted);
|
||||
|
||||
/* Compatibilité ActivityPub (variantes « texte sur fond teinté », AA vérifié) */
|
||||
--compat-full: light-dark(oklch(50% 0.13 165), oklch(75% 0.16 165));
|
||||
--compat-partial: light-dark(oklch(52% 0.12 90), oklch(80% 0.15 90));
|
||||
--compat-extension: light-dark(oklch(45% 0.12 250), oklch(75% 0.13 240));
|
||||
--compat-none: light-dark(oklch(50% 0.19 350), oklch(72% 0.18 350));
|
||||
|
||||
/* Variantes « texte » des accents — AA (≥ 4.5:1) sur surfaces claires et sombres.
|
||||
Les tokens --accent* d'origine restent dédiés aux fonds/bordures décoratives. */
|
||||
--accent-text: light-dark(oklch(50% 0.19 350), oklch(72% 0.18 350));
|
||||
--accent-2-text: light-dark(oklch(45% 0.12 250), oklch(75% 0.13 240));
|
||||
--accent-3-text: light-dark(oklch(48% 0.15 70), oklch(75% 0.16 75));
|
||||
--accent-4-text: light-dark(oklch(45% 0.13 165), oklch(75% 0.16 165));
|
||||
|
||||
/* Variantes « fond » des accents pour boutons avec texte --on-accent (AA ≥ 4.5:1) */
|
||||
--accent-strong: oklch(48% 0.18 350);
|
||||
--accent-3-strong: oklch(46% 0.14 70);
|
||||
--accent-4-strong: oklch(43% 0.12 165);
|
||||
|
||||
/* Familles de polices (2 fichiers WOFF2 auto-hébergés, piles système en repli) */
|
||||
--font-display: 'Space Grotesk', 'Inter', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Rayons, ombres, halos */
|
||||
--radius-1: 0.5rem;
|
||||
--radius-2: 1rem;
|
||||
--radius-3: 1.5rem;
|
||||
--radius-pill: 999px;
|
||||
--shadow-1: 0 0.125rem 0.5rem oklch(10% 0.02 260 / 0.15);
|
||||
--shadow-2: 0 0.5rem 2rem oklch(10% 0.02 260 / 0.25);
|
||||
--glow-accent: 0 0 1.5rem oklch(55% 0.2 350 / 0.45);
|
||||
--glow-accent-2: 0 0 1.5rem oklch(70% 0.15 250 / 0.35);
|
||||
|
||||
/* Dégradés de marque (les usages sont tous du texte en background-clip :
|
||||
construit sur les variantes « texte » pour rester AA dans les deux modes) */
|
||||
--gradient-brand: linear-gradient(135deg, var(--accent-text), var(--accent-2-text));
|
||||
--gradient-abyss: linear-gradient(180deg, var(--ocean), var(--abyss));
|
||||
|
||||
/* Structure */
|
||||
--header-h: 4.5rem;
|
||||
--content-max: 75rem;
|
||||
|
||||
/* Transitions (désactivées via prefers-reduced-motion dans base.css) */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--duration-1: 150ms;
|
||||
--duration-2: 300ms;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* tokens.css — Design tokens (oklch, typo fluide, espacements)
|
||||
Méthodologie modus-vivendi : color-scheme light/dark via light-dark(). */
|
||||
|
||||
@layer tokens {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Couleurs de base */
|
||||
--ink: light-dark(oklch(22% 0.02 260), oklch(92% 0.01 260));
|
||||
--surface: light-dark(oklch(98% 0.005 90), oklch(15% 0.02 260));
|
||||
--surface-2: light-dark(oklch(95% 0.01 90), oklch(20% 0.02 260));
|
||||
--accent: oklch(55% 0.2 350); /* rose coral #F72585 */
|
||||
--accent-2: oklch(70% 0.15 250); /* bleu #4CC9F0 */
|
||||
--accent-3: oklch(65% 0.18 80); /* orange Nostr #F7931A */
|
||||
--accent-4: oklch(70% 0.15 160); /* vert SimpleX #06D6A0 */
|
||||
--muted: light-dark(oklch(45% 0.02 260), oklch(70% 0.02 260));
|
||||
|
||||
/* Typographie fluide */
|
||||
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
|
||||
--text-2: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem);
|
||||
--text-3: clamp(1.5rem, 1.3rem + 1vw, 2rem);
|
||||
--text-4: clamp(2rem, 1.8rem + 1.2vw, 3rem);
|
||||
--text-small: clamp(0.875rem, 0.85rem + 0.1vw, 0.9375rem);
|
||||
|
||||
/* Espacements */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
|
||||
/* Divers */
|
||||
--tap-target: 48px;
|
||||
--measure: 65ch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Diagnostic de démarrage — script classique externe (compatible CSP « script-src 'self' »).
|
||||
Affiche le bandeau #boot-warning si l'app SvelteKit n'a pas démarré après 8 s
|
||||
(modules bloqués, ex. ouverture en file:// ou déploiement incomplet). */
|
||||
window.setTimeout(function () {
|
||||
if (!document.documentElement.dataset.js) {
|
||||
var warning = document.getElementById('boot-warning');
|
||||
if (warning) warning.hidden = false;
|
||||
}
|
||||
}, 8000);
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"id": "social",
|
||||
"name": "Réseaux sociaux",
|
||||
"icon": "Users",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Plateformes sociales fédérées"
|
||||
},
|
||||
{
|
||||
"id": "microblog",
|
||||
"name": "Microblogging",
|
||||
"icon": "MessageSquare",
|
||||
"color": "#F9C74F",
|
||||
"description": "Micro-blogs et status updates"
|
||||
},
|
||||
{
|
||||
"id": "photo",
|
||||
"name": "Photographie",
|
||||
"icon": "Camera",
|
||||
"color": "#F8961E",
|
||||
"description": "Partage de photos et images"
|
||||
},
|
||||
{
|
||||
"id": "video",
|
||||
"name": "Vidéo",
|
||||
"icon": "Video",
|
||||
"color": "#F72585",
|
||||
"description": "Streaming et partage vidéo"
|
||||
},
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "Audio & Podcast",
|
||||
"icon": "Headphones",
|
||||
"color": "#B5179E",
|
||||
"description": "Musique, podcasts et audio"
|
||||
},
|
||||
{
|
||||
"id": "blog",
|
||||
"name": "Blogs",
|
||||
"icon": "FileText",
|
||||
"color": "#7209B7",
|
||||
"description": "Plateformes de blogging"
|
||||
},
|
||||
{
|
||||
"id": "forum",
|
||||
"name": "Forums",
|
||||
"icon": "MessageCircle",
|
||||
"color": "#3A0CA3",
|
||||
"description": "Forums et agrégateurs"
|
||||
},
|
||||
{
|
||||
"id": "events",
|
||||
"name": "Événements",
|
||||
"icon": "Calendar",
|
||||
"color": "#4361EE",
|
||||
"description": "Gestion d'événements"
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Développement",
|
||||
"icon": "Code",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Forge et développement logiciel"
|
||||
},
|
||||
{
|
||||
"id": "collab",
|
||||
"name": "Collaboration",
|
||||
"icon": "FolderOpen",
|
||||
"color": "#2EC4B6",
|
||||
"description": "Outils collaboratifs et cloud"
|
||||
},
|
||||
{
|
||||
"id": "books",
|
||||
"name": "Livres",
|
||||
"icon": "BookOpen",
|
||||
"color": "#E71D36",
|
||||
"description": "Lecture et partage de livres"
|
||||
},
|
||||
{
|
||||
"id": "wiki",
|
||||
"name": "Wiki",
|
||||
"icon": "Globe",
|
||||
"color": "#662E9B",
|
||||
"description": "Encyclopédies collaboratives"
|
||||
},
|
||||
{
|
||||
"id": "messaging",
|
||||
"name": "Messagerie",
|
||||
"icon": "Send",
|
||||
"color": "#06D6A0",
|
||||
"description": "Messagerie instantanée privée et sécurisée"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"icon": "Zap",
|
||||
"color": "#F7931A",
|
||||
"description": "Écosystème Nostr — protocole voisin"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
- id: social
|
||||
name: Réseaux sociaux
|
||||
icon: Users
|
||||
color: '#4CC9F0'
|
||||
description: Plateformes sociales fédérées
|
||||
- id: microblog
|
||||
name: Microblogging
|
||||
icon: MessageSquare
|
||||
color: '#F9C74F'
|
||||
description: Micro-blogs et status updates
|
||||
- id: photo
|
||||
name: Photographie
|
||||
icon: Camera
|
||||
color: '#F8961E'
|
||||
description: Partage de photos et images
|
||||
- id: video
|
||||
name: Vidéo
|
||||
icon: Video
|
||||
color: '#F72585'
|
||||
description: Streaming et partage vidéo
|
||||
- id: audio
|
||||
name: Audio & Podcast
|
||||
icon: Headphones
|
||||
color: '#B5179E'
|
||||
description: Musique, podcasts et audio
|
||||
- id: blog
|
||||
name: Blogs
|
||||
icon: FileText
|
||||
color: '#7209B7'
|
||||
description: Plateformes de blogging
|
||||
- id: forum
|
||||
name: Forums
|
||||
icon: MessageCircle
|
||||
color: '#3A0CA3'
|
||||
description: Forums et agrégateurs
|
||||
- id: events
|
||||
name: Événements
|
||||
icon: Calendar
|
||||
color: '#4361EE'
|
||||
description: Gestion d'événements
|
||||
- id: dev
|
||||
name: Développement
|
||||
icon: Code
|
||||
color: '#4CC9F0'
|
||||
description: Forge et développement logiciel
|
||||
- id: collab
|
||||
name: Collaboration
|
||||
icon: FolderOpen
|
||||
color: '#2EC4B6'
|
||||
description: Outils collaboratifs et cloud
|
||||
- id: books
|
||||
name: Livres
|
||||
icon: BookOpen
|
||||
color: '#E71D36'
|
||||
description: Lecture et partage de livres
|
||||
- id: wiki
|
||||
name: Wiki
|
||||
icon: Globe
|
||||
color: '#662E9B'
|
||||
description: Encyclopédies collaboratives
|
||||
- id: messaging
|
||||
name: Messagerie
|
||||
icon: Send
|
||||
color: '#06D6A0'
|
||||
description: Messagerie instantanée privée et sécurisée
|
||||
- id: nostr
|
||||
name: Nostr
|
||||
icon: Zap
|
||||
color: '#F7931A'
|
||||
description: Écosystème Nostr — protocole voisin
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Le Grand Atlas du Fédiverse",
|
||||
"version": "4.0",
|
||||
"year": 2026,
|
||||
"language": "fr",
|
||||
"licence": "CC BY-SA 4.0",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"statistics": {
|
||||
"totalSoftwares": 105,
|
||||
"totalProtocols": 12,
|
||||
"totalCategories": 14,
|
||||
"activeSoftwares": 93,
|
||||
"maintenanceSoftwares": 4,
|
||||
"experimentalSoftwares": 5,
|
||||
"historicalSoftwares": 3,
|
||||
"fullAPCompatibility": 45,
|
||||
"partialAPCompatibility": 4,
|
||||
"extensionAPCompatibility": 9
|
||||
},
|
||||
"attribution": {
|
||||
"inspiredBy": "Per Axbom (axbom.com/fediverse)",
|
||||
"originalVersion": "3.0 (January 2023)",
|
||||
"maintainers": [
|
||||
"Community"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
title: Le Grand Atlas du Fédiverse
|
||||
version: '4.0'
|
||||
year: 2026
|
||||
language: fr
|
||||
licence: CC BY-SA 4.0
|
||||
lastUpdated: '2026-07-12'
|
||||
statistics:
|
||||
totalSoftwares: 105
|
||||
totalProtocols: 12
|
||||
totalCategories: 14
|
||||
activeSoftwares: 93
|
||||
maintenanceSoftwares: 4
|
||||
experimentalSoftwares: 5
|
||||
historicalSoftwares: 3
|
||||
fullAPCompatibility: 45
|
||||
partialAPCompatibility: 4
|
||||
extensionAPCompatibility: 9
|
||||
attribution:
|
||||
inspiredBy: Per Axbom (axbom.com/fediverse)
|
||||
originalVersion: 3.0 (January 2023)
|
||||
maintainers:
|
||||
- Community
|
||||
@@ -0,0 +1,97 @@
|
||||
[
|
||||
{
|
||||
"id": "activitypub",
|
||||
"name": "ActivityPub",
|
||||
"description": "Protocole de réseautage social décentralisé recommandé par le W3C. Le standard principal du Fédiverse.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"site": "https://activitypub.rocks/",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "activitystreams",
|
||||
"name": "ActivityStreams 2.0",
|
||||
"description": "Format de données pour décrire les activités sociales. Base sur laquelle repose ActivityPub.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "webfinger",
|
||||
"name": "WebFinger",
|
||||
"description": "Protocole de découverte d'informations sur les ressources identifiées par des URI web.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 7033"
|
||||
},
|
||||
{
|
||||
"id": "httpsignatures",
|
||||
"name": "HTTP Signatures",
|
||||
"description": "Mécanisme de signature des requêtes HTTP pour l'authentification entre instances.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "IETF Draft"
|
||||
},
|
||||
{
|
||||
"id": "nodeinfo",
|
||||
"name": "NodeInfo",
|
||||
"description": "Schéma de métadonnées pour décrire les instances fédérées et leurs capacités.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "nodeinfo.diaspora.software"
|
||||
},
|
||||
{
|
||||
"id": "zot",
|
||||
"name": "Zot / Nomad",
|
||||
"description": "Protocole de communications décentralisées avec prise en charge avancée de la privacy et du nomadisme d'identité.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://zotlabs.org/"
|
||||
},
|
||||
{
|
||||
"id": "diaspora",
|
||||
"name": "Diaspora* Protocol",
|
||||
"description": "Protocole historique du réseau Diaspora*, précurseur du Fédiverse moderne.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "ostatus",
|
||||
"name": "OStatus",
|
||||
"description": "Suite de protocoles (Atom, Salmon, WebSub) utilisée par les premières plateformes fédérées.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "matrix",
|
||||
"name": "Matrix Protocol",
|
||||
"description": "Protocole ouvert pour la communication temps réel (chat, VoIP). Interopère avec ActivityPub.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://matrix.org/"
|
||||
},
|
||||
{
|
||||
"id": "xmpp",
|
||||
"name": "XMPP",
|
||||
"description": "Extensible Messaging and Presence Protocol. Standard historique de messagerie instantanée.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 6120"
|
||||
},
|
||||
{
|
||||
"id": "atproto",
|
||||
"name": "AT Protocol",
|
||||
"description": "Protocole d'identité décentralisé utilisé par Bluesky. Écosystème voisin mais distinct du Fédiverse.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://atproto.com/"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"description": "Protocole simple et ouvert pour créer des réseaux sociaux résistants à la censure.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://nostr.com/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
- id: activitypub
|
||||
name: ActivityPub
|
||||
description: Protocole de réseautage social décentralisé recommandé par le W3C.
|
||||
Le standard principal du Fédiverse.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
site: https://activitypub.rocks/
|
||||
rfc: W3C Recommendation
|
||||
- id: activitystreams
|
||||
name: ActivityStreams 2.0
|
||||
description: Format de données pour décrire les activités sociales. Base sur laquelle
|
||||
repose ActivityPub.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: W3C Recommendation
|
||||
- id: webfinger
|
||||
name: WebFinger
|
||||
description: Protocole de découverte d'informations sur les ressources identifiées
|
||||
par des URI web.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: RFC 7033
|
||||
- id: httpsignatures
|
||||
name: HTTP Signatures
|
||||
description: Mécanisme de signature des requêtes HTTP pour l'authentification entre
|
||||
instances.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: IETF Draft
|
||||
- id: nodeinfo
|
||||
name: NodeInfo
|
||||
description: Schéma de métadonnées pour décrire les instances fédérées et leurs
|
||||
capacités.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: nodeinfo.diaspora.software
|
||||
- id: zot
|
||||
name: Zot / Nomad
|
||||
description: Protocole de communications décentralisées avec prise en charge avancée
|
||||
de la privacy et du nomadisme d'identité.
|
||||
type: connexe
|
||||
statut: Actif
|
||||
site: https://zotlabs.org/
|
||||
- id: diaspora
|
||||
name: Diaspora* Protocol
|
||||
description: Protocole historique du réseau Diaspora*, précurseur du Fédiverse moderne.
|
||||
type: connexe
|
||||
statut: Historique
|
||||
- id: ostatus
|
||||
name: OStatus
|
||||
description: Suite de protocoles (Atom, Salmon, WebSub) utilisée par les premières
|
||||
plateformes fédérées.
|
||||
type: connexe
|
||||
statut: Historique
|
||||
- id: matrix
|
||||
name: Matrix Protocol
|
||||
description: Protocole ouvert pour la communication temps réel (chat, VoIP). Interopère
|
||||
avec ActivityPub.
|
||||
type: connexe
|
||||
statut: Actif
|
||||
site: https://matrix.org/
|
||||
- id: xmpp
|
||||
name: XMPP
|
||||
description: Extensible Messaging and Presence Protocol. Standard historique de
|
||||
messagerie instantanée.
|
||||
type: connexe
|
||||
statut: Actif
|
||||
rfc: RFC 6120
|
||||
- id: atproto
|
||||
name: AT Protocol
|
||||
description: Protocole d'identité décentralisé utilisé par Bluesky. Écosystème voisin
|
||||
mais distinct du Fédiverse.
|
||||
type: voisin
|
||||
statut: En développement
|
||||
site: https://atproto.com/
|
||||
- id: nostr
|
||||
name: Nostr
|
||||
description: Protocole simple et ouvert pour créer des réseaux sociaux résistants
|
||||
à la censure.
|
||||
type: voisin
|
||||
statut: En développement
|
||||
site: https://nostr.com/
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Logo Atlas Fédiverse">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#F72585" />
|
||||
<stop offset="1" stop-color="#4CC9F0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="256" cy="256" r="196" fill="#012A4A" />
|
||||
<circle cx="256" cy="256" r="196" fill="none" stroke="url(#g)" stroke-width="20" />
|
||||
<ellipse cx="256" cy="256" rx="92" ry="196" fill="none" stroke="#4CC9F0" stroke-width="12" opacity="0.75" />
|
||||
<path d="M 74 256 H 438" stroke="#4CC9F0" stroke-width="12" opacity="0.75" />
|
||||
<path d="M 104 168 Q 256 226 408 168" fill="none" stroke="#4CC9F0" stroke-width="10" opacity="0.5" />
|
||||
<path d="M 104 344 Q 256 286 408 344" fill="none" stroke="#4CC9F0" stroke-width="10" opacity="0.5" />
|
||||
<circle cx="256" cy="60" r="20" fill="#F72585" />
|
||||
<circle cx="122" cy="336" r="16" fill="#06D6A0" />
|
||||
<circle cx="390" cy="336" r="16" fill="#F9C74F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 983 B |
@@ -0,0 +1,352 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3508 4961" width="3508px" height="4961px">
|
||||
<rect width="3508" height="4961" fill="#001219"/>
|
||||
<text x="1754" y="120" text-anchor="middle" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="72" font-weight="700">Le Grand Atlas du Fédiverse</text>
|
||||
<text x="1754" y="180" text-anchor="middle" fill="#F72585" font-family="Space Grotesk, sans-serif" font-size="36" font-weight="600">Version 4.0 · 2026</text>
|
||||
<text x="1754" y="230" text-anchor="middle" fill="#A9D6E5" font-family="JetBrains Mono, monospace" font-size="20">90 logiciels · 14 catégories · 12 protocoles</text>
|
||||
<circle cx="1754" cy="2380" r="200" fill="none" stroke="#4CC9F0" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1954.0" cy="2380.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1954.0" cy="2380.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1984.0" y="2380.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mastodon</text>
|
||||
<circle cx="1944.2" cy="2441.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1944.2" cy="2441.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1972.7" y="2451.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">GoToSocial</text>
|
||||
<circle cx="1915.8" cy="2497.6" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1915.8" cy="2497.6" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1940.1" y="2515.2" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Akkoma</text>
|
||||
<circle cx="1871.6" cy="2541.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1871.6" cy="2541.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1889.2" y="2566.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Pleroma</text>
|
||||
<circle cx="1815.8" cy="2570.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1815.8" cy="2570.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1825.1" y="2598.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Misskey</text>
|
||||
<circle cx="1754.0" cy="2580.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1754.0" cy="2580.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1754.0" y="2610.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Sharkey</text>
|
||||
<circle cx="1692.2" cy="2570.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1692.2" cy="2570.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1682.9" y="2598.7" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Friendica</text>
|
||||
<circle cx="1636.4" cy="2541.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1636.4" cy="2541.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1618.8" y="2566.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Hubzilla</text>
|
||||
<circle cx="1592.2" cy="2497.6" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1592.2" cy="2497.6" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1567.9" y="2515.2" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Streams</text>
|
||||
<circle cx="1563.8" cy="2441.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1563.8" cy="2441.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1535.3" y="2451.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Iceshrimp</text>
|
||||
<circle cx="1554.0" cy="2380.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1554.0" cy="2380.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1524.0" y="2380.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">GNU social</text>
|
||||
<circle cx="1563.8" cy="2318.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1563.8" cy="2318.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1535.3" y="2308.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Bonfire</text>
|
||||
<circle cx="1592.2" cy="2262.4" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1592.2" cy="2262.4" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1567.9" y="2244.8" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Diaspora*</text>
|
||||
<circle cx="1636.4" cy="2218.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1636.4" cy="2218.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1618.8" y="2193.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mitra</text>
|
||||
<circle cx="1692.2" cy="2189.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1692.2" cy="2189.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1682.9" y="2161.3" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Socialhome</text>
|
||||
<circle cx="1754.0" cy="2180.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1754.0" cy="2180.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1754.0" y="2150.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Epicyon</text>
|
||||
<circle cx="1815.8" cy="2189.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1815.8" cy="2189.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1825.1" y="2161.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Takahē</text>
|
||||
<circle cx="1871.6" cy="2218.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1871.6" cy="2218.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1889.2" y="2193.9" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Smithereen</text>
|
||||
<circle cx="1915.8" cy="2262.4" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1915.8" cy="2262.4" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1940.1" y="2244.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Hometown</text>
|
||||
<circle cx="1944.2" cy="2318.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1944.2" cy="2318.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1972.7" y="2308.9" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">GlitchSoc</text>
|
||||
<circle cx="1754" cy="2380" r="265" fill="none" stroke="#F7931A" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1994.2" cy="2492.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1994.2" cy="2492.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2021.4" y="2504.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nostr Protocol</text>
|
||||
<circle cx="1927.9" cy="2580.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1927.9" cy="2580.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1947.5" y="2602.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Damus</text>
|
||||
<circle cx="1831.5" cy="2633.4" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1831.5" cy="2633.4" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1840.2" y="2662.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Amethyst</text>
|
||||
<circle cx="1721.7" cy="2643.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1721.7" cy="2643.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1718.0" y="2672.8" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Iris</text>
|
||||
<circle cx="1617.5" cy="2607.1" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1617.5" cy="2607.1" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1602.1" y="2632.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Primal</text>
|
||||
<circle cx="1536.9" cy="2532.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1536.9" cy="2532.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1512.4" y="2549.2" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Habla.news</text>
|
||||
<circle cx="1493.9" cy="2430.6" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1493.9" cy="2430.6" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1464.4" y="2436.3" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nostr.band</text>
|
||||
<circle cx="1495.8" cy="2320.4" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1495.8" cy="2320.4" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1466.6" y="2313.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Snort</text>
|
||||
<circle cx="1542.4" cy="2220.5" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1542.4" cy="2220.5" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1518.4" y="2202.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Coracle</text>
|
||||
<circle cx="1625.5" cy="2148.2" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1625.5" cy="2148.2" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1611.0" y="2122.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nostrudel</text>
|
||||
<circle cx="1730.9" cy="2116.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1730.9" cy="2116.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1728.3" y="2086.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Lume</text>
|
||||
<circle cx="1840.3" cy="2129.4" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1840.3" cy="2129.4" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1850.0" y="2101.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gossip</text>
|
||||
<circle cx="1934.7" cy="2186.2" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1934.7" cy="2186.2" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1955.2" y="2164.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Current</text>
|
||||
<circle cx="1997.9" cy="2276.5" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1997.9" cy="2276.5" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2025.5" y="2264.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Plebstr</text>
|
||||
<circle cx="2019.0" cy="2384.6" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="2019.0" cy="2384.6" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2049.0" y="2385.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ZapStream</text>
|
||||
<circle cx="1754" cy="2380" r="330" fill="none" stroke="#06D6A0" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1966.1" cy="2632.8" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1966.1" cy="2632.8" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1985.4" y="2655.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">SimpleX Chat</text>
|
||||
<circle cx="1824.3" cy="2702.4" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1824.3" cy="2702.4" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1830.7" y="2731.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Conversations</text>
|
||||
<circle cx="1666.5" cy="2698.2" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1666.5" cy="2698.2" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1658.5" y="2727.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Dino</text>
|
||||
<circle cx="1528.6" cy="2621.0" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1528.6" cy="2621.0" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1508.1" y="2643.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gajim</text>
|
||||
<circle cx="1442.4" cy="2488.7" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1442.4" cy="2488.7" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1414.1" y="2498.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Monal</text>
|
||||
<circle cx="1427.6" cy="2331.4" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1427.6" cy="2331.4" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1397.9" y="2327.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Snikket</text>
|
||||
<circle cx="1487.5" cy="2185.3" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1487.5" cy="2185.3" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1463.3" y="2167.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Movim</text>
|
||||
<circle cx="1608.5" cy="2083.8" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1608.5" cy="2083.8" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1595.3" y="2056.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Briar</text>
|
||||
<circle cx="1762.9" cy="2050.1" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1762.9" cy="2050.1" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1763.7" y="2020.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Jami</text>
|
||||
<circle cx="1915.1" cy="2092.0" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1915.1" cy="2092.0" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1929.8" y="2065.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Prosody</text>
|
||||
<circle cx="2030.5" cy="2199.9" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="2030.5" cy="2199.9" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2055.7" y="2183.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ejabberd</text>
|
||||
<circle cx="2082.5" cy="2349.0" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="2082.5" cy="2349.0" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2112.4" y="2346.2" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Openfire</text>
|
||||
<circle cx="2059.3" cy="2505.3" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="2059.3" cy="2505.3" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2087.1" y="2516.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">jabber</text>
|
||||
<circle cx="1754" cy="2380" r="395" fill="none" stroke="#2EC4B6" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1856.2" cy="2761.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1856.2" cy="2761.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1864.0" y="2790.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nextcloud</text>
|
||||
<circle cx="1651.8" cy="2761.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1651.8" cy="2761.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1644.0" y="2790.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ownCloud</text>
|
||||
<circle cx="1474.7" cy="2659.3" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1474.7" cy="2659.3" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1453.5" y="2680.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Element</text>
|
||||
<circle cx="1372.5" cy="2482.2" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1372.5" cy="2482.2" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1343.5" y="2490.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">CryptPad</text>
|
||||
<circle cx="1372.5" cy="2277.8" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1372.5" cy="2277.8" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1343.5" y="2270.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">HedgeDoc</text>
|
||||
<circle cx="1474.7" cy="2100.7" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1474.7" cy="2100.7" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1453.5" y="2079.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Immich</text>
|
||||
<circle cx="1651.8" cy="1998.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1651.8" cy="1998.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1644.0" y="1969.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">PhotoPrism</text>
|
||||
<circle cx="1856.2" cy="1998.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1856.2" cy="1998.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1864.0" y="1969.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Synapse</text>
|
||||
<circle cx="2033.3" cy="2100.7" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2033.3" cy="2100.7" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2054.5" y="2079.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Dendrite</text>
|
||||
<circle cx="2135.5" cy="2277.8" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2135.5" cy="2277.8" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2164.5" y="2270.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Conduit</text>
|
||||
<circle cx="2135.5" cy="2482.2" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2135.5" cy="2482.2" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2164.5" y="2490.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Cinny</text>
|
||||
<circle cx="2033.3" cy="2659.3" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2033.3" cy="2659.3" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2054.5" y="2680.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">FluffyChat</text>
|
||||
<circle cx="1754" cy="2380" r="460" fill="none" stroke="#7209B7" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1674.1" cy="2833.0" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="1674.1" cy="2833.0" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1668.9" y="2862.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Forgejo</text>
|
||||
<circle cx="1298.5" cy="2444.0" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="1298.5" cy="2444.0" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1268.8" y="2448.2" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gitea</text>
|
||||
<circle cx="1552.3" cy="1966.6" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="1552.3" cy="1966.6" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1539.2" y="1939.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Fedify</text>
|
||||
<circle cx="2084.9" cy="2060.5" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="2084.9" cy="2060.5" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2106.5" y="2039.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ActivityPub Relay</text>
|
||||
<circle cx="2160.2" cy="2596.0" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="2160.2" cy="2596.0" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2186.6" y="2610.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">FediBridge</text>
|
||||
<circle cx="1754" cy="2380" r="525" fill="none" stroke="#3A0CA3" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1452.9" cy="2810.1" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1452.9" cy="2810.1" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1435.7" y="2834.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Lemmy</text>
|
||||
<circle cx="1231.0" cy="2334.2" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1231.0" cy="2334.2" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1201.1" y="2331.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mbin</text>
|
||||
<circle cx="1532.1" cy="1904.2" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1532.1" cy="1904.2" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1519.4" y="1877.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">NodeBB</text>
|
||||
<circle cx="2055.1" cy="1949.9" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="2055.1" cy="1949.9" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2072.3" y="1925.4" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Lotide</text>
|
||||
<circle cx="2277.0" cy="2425.8" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="2277.0" cy="2425.8" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2306.9" y="2428.4" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Sublinks</text>
|
||||
<circle cx="1975.9" cy="2855.8" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1975.9" cy="2855.8" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1988.6" y="2883.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">kbin</text>
|
||||
<circle cx="1754" cy="2380" r="590" fill="none" stroke="#F9C74F" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1243.0" cy="2675.0" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1243.0" cy="2675.0" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1217.1" y="2690.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Ghost</text>
|
||||
<circle cx="1315.5" cy="1985.2" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1315.5" cy="1985.2" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1293.3" y="1965.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">WriteFreely</text>
|
||||
<circle cx="1994.0" cy="1841.0" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1994.0" cy="1841.0" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2006.2" y="1813.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Plume</text>
|
||||
<circle cx="2340.8" cy="2441.7" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="2340.8" cy="2441.7" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2370.6" y="2444.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">WordPress + AP</text>
|
||||
<circle cx="1876.7" cy="2957.1" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1876.7" cy="2957.1" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1882.9" y="2986.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Drupal + AP</text>
|
||||
<circle cx="1754" cy="2380" r="655" fill="none" stroke="#F72585" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1101.5" cy="2437.1" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="1101.5" cy="2437.1" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1071.6" y="2439.7" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">PeerTube</text>
|
||||
<circle cx="1498.1" cy="1777.1" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="1498.1" cy="1777.1" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1486.3" y="1749.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Owncast</text>
|
||||
<circle cx="2248.3" cy="1950.3" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="2248.3" cy="1950.3" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2271.0" y="1930.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Tube (Framasoft)</text>
|
||||
<circle cx="2315.4" cy="2717.3" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="2315.4" cy="2717.3" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2341.2" y="2732.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Loops</text>
|
||||
<circle cx="1606.7" cy="3018.2" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="1606.7" cy="3018.2" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1599.9" y="3047.4" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Sepia Search</text>
|
||||
<circle cx="1754" cy="2380" r="720" fill="none" stroke="#B5179E" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1077.4" cy="2133.7" r="6" fill="#B5179E" opacity="0.9"/>
|
||||
<circle cx="1077.4" cy="2133.7" r="10" fill="none" stroke="#B5179E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1049.2" y="2123.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Castopod</text>
|
||||
<circle cx="2430.6" cy="2626.3" r="6" fill="#B5179E" opacity="0.9"/>
|
||||
<circle cx="2430.6" cy="2626.3" r="10" fill="none" stroke="#B5179E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2458.8" y="2636.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Funkwhale</text>
|
||||
<circle cx="1754" cy="2380" r="785" fill="none" stroke="#F8961E" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1198.9" cy="1824.9" r="6" fill="#F8961E" opacity="0.9"/>
|
||||
<circle cx="1198.9" cy="1824.9" r="10" fill="none" stroke="#F8961E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1177.7" y="1803.7" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">PixelFed</text>
|
||||
<circle cx="2309.1" cy="2935.1" r="6" fill="#F8961E" opacity="0.9"/>
|
||||
<circle cx="2309.1" cy="2935.1" r="10" fill="none" stroke="#F8961E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2330.3" y="2956.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Vernissage</text>
|
||||
<circle cx="1754" cy="2380" r="850" fill="none" stroke="#4361EE" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1463.3" cy="1581.3" r="6" fill="#4361EE" opacity="0.9"/>
|
||||
<circle cx="1463.3" cy="1581.3" r="10" fill="none" stroke="#4361EE" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1453.0" y="1553.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mobilizon</text>
|
||||
<circle cx="2044.7" cy="3178.7" r="6" fill="#4361EE" opacity="0.9"/>
|
||||
<circle cx="2044.7" cy="3178.7" r="10" fill="none" stroke="#4361EE" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2055.0" y="3206.9" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gancio</text>
|
||||
<circle cx="1754" cy="2380" r="915" fill="none" stroke="#E71D36" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1833.7" cy="1468.5" r="6" fill="#E71D36" opacity="0.9"/>
|
||||
<circle cx="1833.7" cy="1468.5" r="10" fill="none" stroke="#E71D36" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1836.4" y="1438.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">microblog.pub</text>
|
||||
<circle cx="1754" cy="2380" r="980" fill="none" stroke="#E71D36" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="2244.0" cy="1531.3" r="6" fill="#E71D36" opacity="0.9"/>
|
||||
<circle cx="2244.0" cy="1531.3" r="10" fill="none" stroke="#E71D36" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2259.0" y="1505.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">BookWyrm</text>
|
||||
<circle cx="1754" cy="2380" r="1045" fill="none" stroke="#662E9B" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="2610.0" cy="1780.6" r="6" fill="#662E9B" opacity="0.9"/>
|
||||
<circle cx="2610.0" cy="1780.6" r="10" fill="none" stroke="#662E9B" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2634.6" y="1763.4" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Wikidata</text>
|
||||
<circle cx="1754" cy="2380" r="170" fill="none" stroke="#F72585" stroke-width="3"/>
|
||||
<circle cx="1754" cy="2380" r="150" fill="none" stroke="#4CC9F0" stroke-width="1.5" opacity="0.5"/>
|
||||
<text x="1754" y="2365" text-anchor="middle" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="28" font-weight="700">LE FÉDIVERSE</text>
|
||||
<text x="1754" y="2395" text-anchor="middle" fill="#F72585" font-family="JetBrains Mono, monospace" font-size="14">ActivityPub · 2026</text>
|
||||
<text x="1754" y="2420" text-anchor="middle" fill="#A9D6E5" font-family="Inter, sans-serif" font-size="12">90 logiciels fédérés</text>
|
||||
<line x1="200" y1="4411" x2="3308" y2="4411" stroke="#FFFFFF" stroke-width="1" opacity="0.2"/>
|
||||
<text x="1754" y="4461" text-anchor="middle" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="32" font-weight="600">Protocoles</text>
|
||||
<rect x="324" y="4501" width="220" height="50" rx="8" fill="#F7258515" stroke="#F7258540" stroke-width="1"/>
|
||||
<text x="336" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">ActivityPub</text>
|
||||
<text x="336" y="4541" fill="#F72585" font-family="JetBrains Mono, monospace" font-size="10">W3C Recommendation</text>
|
||||
<rect x="564" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="576" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">WebFinger</text>
|
||||
<text x="576" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">RFC 7033</text>
|
||||
<rect x="804" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="816" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">HTTP Signatures</text>
|
||||
<text x="816" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">IETF Draft</text>
|
||||
<rect x="1044" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="1056" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">NodeInfo</text>
|
||||
<text x="1056" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">Active</text>
|
||||
<rect x="1284" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="1296" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">ActivityStreams</text>
|
||||
<text x="1296" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">W3C</text>
|
||||
<rect x="1524" y="4501" width="220" height="50" rx="8" fill="#F9C74F15" stroke="#F9C74F40" stroke-width="1"/>
|
||||
<text x="1536" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Zot/Nomad</text>
|
||||
<text x="1536" y="4541" fill="#F9C74F" font-family="JetBrains Mono, monospace" font-size="10">Hubzilla/Streams</text>
|
||||
<rect x="1764" y="4501" width="220" height="50" rx="8" fill="#6C757D15" stroke="#6C757D40" stroke-width="1"/>
|
||||
<text x="1776" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Diaspora*</text>
|
||||
<text x="1776" y="4541" fill="#6C757D" font-family="JetBrains Mono, monospace" font-size="10">Historical</text>
|
||||
<rect x="2004" y="4501" width="220" height="50" rx="8" fill="#6C757D15" stroke="#6C757D40" stroke-width="1"/>
|
||||
<text x="2016" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">OStatus</text>
|
||||
<text x="2016" y="4541" fill="#6C757D" font-family="JetBrains Mono, monospace" font-size="10">Historical</text>
|
||||
<rect x="2244" y="4501" width="220" height="50" rx="8" fill="#4361EE15" stroke="#4361EE40" stroke-width="1"/>
|
||||
<text x="2256" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Matrix</text>
|
||||
<text x="2256" y="4541" fill="#4361EE" font-family="JetBrains Mono, monospace" font-size="10">Real-time</text>
|
||||
<rect x="2484" y="4501" width="220" height="50" rx="8" fill="#06D6A015" stroke="#06D6A040" stroke-width="1"/>
|
||||
<text x="2496" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">XMPP</text>
|
||||
<text x="2496" y="4541" fill="#06D6A0" font-family="JetBrains Mono, monospace" font-size="10">RFC 6120</text>
|
||||
<rect x="2724" y="4501" width="220" height="50" rx="8" fill="#F7931A15" stroke="#F7931A40" stroke-width="1"/>
|
||||
<text x="2736" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Nostr</text>
|
||||
<text x="2736" y="4541" fill="#F7931A" font-family="JetBrains Mono, monospace" font-size="10">Neighbor</text>
|
||||
<rect x="2964" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="2976" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">AT Protocol</text>
|
||||
<text x="2976" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">Bluesky</text>
|
||||
<text x="1754" y="4661" text-anchor="middle" fill="#A9D6E5" font-family="Space Grotesk, sans-serif" font-size="20">Légende</text>
|
||||
<circle cx="954" cy="4691" r="8" fill="#4CC9F0"/>
|
||||
<text x="970" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Fédéré ActivityPub</text>
|
||||
<circle cx="1154" cy="4691" r="8" fill="#F7931A"/>
|
||||
<text x="1170" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Nostr</text>
|
||||
<circle cx="1354" cy="4691" r="8" fill="#06D6A0"/>
|
||||
<text x="1370" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Messagerie</text>
|
||||
<circle cx="1554" cy="4691" r="8" fill="#F72585"/>
|
||||
<text x="1570" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Multimédia</text>
|
||||
<circle cx="1754" cy="4691" r="8" fill="#2EC4B6"/>
|
||||
<text x="1770" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Collaboration</text>
|
||||
<circle cx="1954" cy="4691" r="8" fill="#7209B7"/>
|
||||
<text x="1970" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Développement</text>
|
||||
<circle cx="2154" cy="4691" r="8" fill="#F9C74F"/>
|
||||
<text x="2170" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Contenu</text>
|
||||
<circle cx="2354" cy="4691" r="8" fill="#6C757D"/>
|
||||
<text x="2370" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Historique</text>
|
||||
<text x="1754" y="4841" text-anchor="middle" fill="#A9D6E5" font-family="JetBrains Mono, monospace" font-size="14">axbom.com/fediverse · CC BY-SA 4.0 · Per Axbom (original v3.0 2023) · Atlas v4.0 2026</text>
|
||||
<text x="1754" y="4871" text-anchor="middle" fill="#F72585" font-family="Inter, sans-serif" font-size="12">legrandatlasdufediverse.org · Données maintenues par la communauté</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Atlas Fediverse",
|
||||
"short_name": "Atlas",
|
||||
"description": "Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé.",
|
||||
"lang": "fr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#001219",
|
||||
"theme_color": "#001219",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined,
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
// Déployé à la racine de https://syel.o-k-i.net (YunoHost / nginx) —
|
||||
// pas de paths.base. La CSP est gérée par build/nginx-csp.conf (snippet
|
||||
// généré au build, à inclure dans la config nginx de l'app).
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
# À placer dans le dossier /chatcontrol/ du serveur (nom exact : .htaccess)
|
||||
# Corrige le blocage CSP du bootstrap SvelteKit de « Lagé Chat Control ».
|
||||
# L'empreinte correspond au script inline actuellement en ligne — si l'app
|
||||
# est reconstruite/redéployée, il faudra la recalculer (voir le script
|
||||
# scripts/postbuild-htaccess.mjs du projet atlas-fediverse pour l'automatiser).
|
||||
# Les « unset » retirent la CSP héritée du domaine : il ne doit en rester qu'une.
|
||||
<IfModule mod_headers.c>
|
||||
Header unset Content-Security-Policy
|
||||
Header always unset Content-Security-Policy
|
||||
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-UncjI7hqLWQQjyQjoBeylEdxk/gehgvmnvF6F0TcB/8='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' https://organisationka.b-cdn.net; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
|
||||
</IfModule>
|
||||
@@ -0,0 +1,239 @@
|
||||
# Prompt : Migration "Le Grand Atlas du Fédiverse" vers PWA SvelteKit
|
||||
|
||||
> Ce prompt est destiné a Kimi CLI. Il transforme le projet "Le Grand Atlas du Fédiverse v4.0" (actuellement React + Vite + Tailwind) en PWA SvelteKit 2 + Svelte 5, en respectant integralement la doctrine UI/UX OKI (document "UI UX souveraine Fondements.md").
|
||||
|
||||
---
|
||||
|
||||
## 1. CONTEXTE DU PROJET
|
||||
|
||||
Le projet est une cartographie interactive du Fediverse (reseau social decentralise) :
|
||||
- **Site web** : https://vhgsr7e3fioam.kimi.page (reference visuelle)
|
||||
- **Donnees** : 105 logiciels, 12 protocoles, 14 categories, chronologie 2008-2026, ponts Nostr-ActivityPub
|
||||
- **Pages** : Accueil (hero shader + graphe 3D + timeline + catalogue + protocoles), page Nostr dediee, page SimpleX dediee
|
||||
- **Stack actuelle** : React 19 + Vite + Tailwind CSS + shadcn/ui + Three.js + D3 (a migrer)
|
||||
- **Licence** : CC BY-SA 4.0
|
||||
- **Langue** : Francais (avec preparation pour bilinguisme FR/créole)
|
||||
|
||||
## 2. STACK CIBLE (OBLIGATOIRE — non negociable)
|
||||
|
||||
```
|
||||
Svelte 5 (runes : $state, $derived, $effect)
|
||||
SvelteKit 2 (SSR par defaut, adapter-static pour PWA)
|
||||
CSS natif sur design tokens (PAS Tailwind, PAS shadcn, PAS framework CSS)
|
||||
Three.js + D3 (conserves pour le graphe 3D — lazy loaded)
|
||||
Bits UI ou Melt UI (headless uniquement — comportement + ARIA, pas de styles imposes)
|
||||
```
|
||||
|
||||
**Pourquoi pas Tailwind** : redondant avec CSS scopé Svelte, disperse le design system dans les templates, court-circuite la comprehension de la cascade. Voir doctrine OKI §5.2.
|
||||
|
||||
## 3. ARCHITECTURE CSS (OBLIGATOIRE)
|
||||
|
||||
```
|
||||
src/styles/
|
||||
tokens.css /* custom properties oklch, typo fluide clamp(), espacements */
|
||||
reset.css /* reset moderne minimal */
|
||||
base.css /* HTML semantique, focus visible */
|
||||
layouts.css /* .stack, .cluster, .sidebar, .center (Every Layout) */
|
||||
themes/
|
||||
default.css /* tokens couleurs du projet */
|
||||
|
||||
src/app.css /* @layer reset, tokens, base, layouts, components, utilities */
|
||||
```
|
||||
|
||||
**Tokens couleurs (oklch, methodologie modus-vivendi)** :
|
||||
```css
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--ink: light-dark(oklch(22% 0.02 260), oklch(92% 0.01 260));
|
||||
--surface: light-dark(oklch(98% 0.005 90), oklch(15% 0.02 260));
|
||||
--surface-2: light-dark(oklch(95% 0.01 90), oklch(20% 0.02 260));
|
||||
--accent: oklch(55% 0.2 350); /* rose coral #F72585 */
|
||||
--accent-2: oklch(70% 0.15 250); /* bleu #4CC9F0 */
|
||||
--accent-3: oklch(65% 0.18 80); /* orange Nostr #F7931A */
|
||||
--accent-4: oklch(70% 0.15 160); /* vert SimpleX #06D6A0 */
|
||||
--muted: oklch(70% 0.02 260);
|
||||
--tap-target: 48px;
|
||||
--measure: 65ch;
|
||||
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
|
||||
--text-2: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem);
|
||||
--text-3: clamp(1.5rem, 1.3rem + 1vw, 2rem);
|
||||
--text-4: clamp(2rem, 1.8rem + 1.2vw, 3rem);
|
||||
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 1rem;
|
||||
--space-4: 1.5rem; --space-5: 2.5rem; --space-6: 4rem;
|
||||
}
|
||||
```
|
||||
|
||||
**Chaque composant Svelte utilise `<style>` scopé** et consomme les tokens — jamais de valeur brute.
|
||||
|
||||
## 4. STRUCTURE SVELTEKIT
|
||||
|
||||
```
|
||||
src/
|
||||
routes/
|
||||
+page.svelte /* Accueil : hero + sections */
|
||||
+page.ts /* prerender = true */
|
||||
nostr/
|
||||
+page.svelte
|
||||
simplex/
|
||||
+page.svelte
|
||||
data/
|
||||
+server.ts /* API JSON pour les donnees */
|
||||
lib/
|
||||
components/
|
||||
Header.svelte
|
||||
Footer.svelte
|
||||
HeroShader.svelte /* Shader WebGL ocean */
|
||||
NetworkGraph.svelte /* Graphe 3D Three.js + D3 */
|
||||
Timeline.svelte /* Chronologie */
|
||||
SoftwareCatalog.svelte /* Catalogue filtrable */
|
||||
ProtocolSection.svelte /* Protocoles */
|
||||
ProtocolStream.svelte /* Data stream animé */
|
||||
data/
|
||||
fediverse.ts /* 105 logiciels, protocoles, categories */
|
||||
timeline.ts /* 30 evenements 2008-2026 */
|
||||
bridges.ts /* Ponts Nostr-AP */
|
||||
styles/
|
||||
tokens.css, reset.css, base.css, layouts.css
|
||||
app.html
|
||||
app.css
|
||||
service-worker.ts /* PWA offline-first */
|
||||
static/
|
||||
manifest.json /* Web App Manifest */
|
||||
icons/ /* 192x192, 512x512 */
|
||||
```
|
||||
|
||||
## 5. PWA OFFLINE-FIRST (OBLIGATOIRE)
|
||||
|
||||
```typescript
|
||||
// src/service-worker.ts
|
||||
// Strategy : Cache-First pour les assets statiques
|
||||
// Network-First pour les donnees JSON
|
||||
// File d'attente pour les actions hors-ligne
|
||||
```
|
||||
|
||||
- **Service Worker** : cache les pages visitées, les donnees JSON, les assets statiques
|
||||
- **Manifest** : `name: "Atlas Fediverse"`, `short_name: "Atlas"`, `display: standalone`, `theme_color: #001219`
|
||||
- **Icons** : generer a partir du SVG logo (globe + couleurs du projet)
|
||||
- **Offline** : le catalogue et les pages Nostr/SimpleX doivent fonctionner hors-ligne apres premiere visite
|
||||
- **Installable** : bouton "Installer" dans le header si beforeinstallprompt disponible
|
||||
|
||||
## 6. PERFORMANCE (OBLIGATOIRE — seuils a respecter)
|
||||
|
||||
| Métrique | Seuil |
|
||||
|----------|-------|
|
||||
| JS initial | < 100 Ko compressé (Three.js lazy loaded) |
|
||||
| CSS total | < 50 Ko |
|
||||
| Page totale | < 500 Ko |
|
||||
| Premier rendu | < 3s en Slow 4G |
|
||||
| Interactions | < 400 ms (skeleton screens si > 1s) |
|
||||
| Polices | ≤ 2 fichiers WOFF2, auto-hebergees, font-display: swap |
|
||||
|
||||
**Lazy loading** : Three.js et D3 ne chargent que sur la route `/` (graphe 3D). Pages Nostr et SimpleX n'embarquent pas Three.js.
|
||||
|
||||
## 7. ACCESSIBILITÉ (OBLIGATOIRE — WCAG 2.2 AA minimum)
|
||||
|
||||
- [ ] Contraste 4.5:1 texte courant, 3:1 grand texte (palette oklch calculée)
|
||||
- [ ] Information jamais portee par la couleur seule (doubler par icone/texte/forme)
|
||||
- [ ] `prefers-color-scheme`, `prefers-reduced-motion`, `prefers-contrast` respectes
|
||||
- [ ] Tout en `rem`, jamais de `font-size` en `px` sur `html`
|
||||
- [ ] HTML semantique : landmarks, hiérarchie titres, `<button>` pour actions
|
||||
- [ ] Focus visible sur tous les elements interactifs
|
||||
- [ ] Ordre de tabulation logique
|
||||
- [ ] Navigation clavier complete (desktop) : `?` pour raccourcis, `/` pour recherche
|
||||
|
||||
## 8. DOCTRINE MOBILE-FIRST (OBLIGATOIRE)
|
||||
|
||||
- Actions primaires dans le **tiers inferieur** (barre de nav basse si justifiée)
|
||||
- Cibles tactiles **≥ 48 px**, espacement **≥ 8 px**, corps **≥ 16 px**
|
||||
- Une colonne, un flux : pas de multi-colonnes sur mobile
|
||||
- Valeur livree en < 5 secondes par ecran
|
||||
- Reprennabilite : etat sauvegarde (filtres catalogue, position scroll)
|
||||
- **Frugalite data** : lazy loading, AVIF/WebP, pas de police web au-dela de 2 fichiers
|
||||
|
||||
## 9. ÉTHIQUE ATTENTIONNELLE (OBLIGATOIRE — calm technology)
|
||||
|
||||
- [ ] **Pagination** ou marqueur "vous etes a jour" dans les listes (PAS de scroll infini)
|
||||
- [ ] Notifications : actionnables uniquement, jamais de re-engagement artificiel
|
||||
- [ ] Pas de compteurs anxiogenes masques/optionnels uniquement
|
||||
- [ ] Pas de dark patterns
|
||||
- [ ] Points d'arret naturels dans les flux
|
||||
|
||||
## 10. DONNÉES (reutiliser telles quelles)
|
||||
|
||||
Les fichiers de donnees du projet actuel sont dans `/public/data/` :
|
||||
- `fediverse.json` : 105 logiciels + protocoles + categories
|
||||
- `software.json`, `protocols.json`, `categories.json`, `network.json`
|
||||
|
||||
Les importer comme modules JSON dans SvelteKit (`import data from './data.json' assert { type: 'json' }`).
|
||||
|
||||
## 11. FEATURES A CONSERVER (parité fonctionnelle)
|
||||
|
||||
### Page d'accueil (`/`)
|
||||
1. **Hero** : Shader WebGL ocean (Le réseau social, sans les chaînes) + CTA "Plonger"
|
||||
2. **Graphe 3D** : Three.js + D3-force, 105 noeuds colorés par categorie, survol tooltip, filtres par categorie
|
||||
3. **Timeline** : Chronologie verticale 2008-2026, filtres par type, alternance gauche/droite
|
||||
4. **Catalogue** : Grille logiciels, recherche instantanee, filtres categorie/statut, modal detail
|
||||
5. **Protocoles** : 12 protocoles en cartes, data stream animé en fond
|
||||
|
||||
### Page Nostr (`/nostr`)
|
||||
- Hero, comment ca marche (3 etapes), 6 features, 15 clients, tableau comparatif AP vs Nostr
|
||||
|
||||
### Page SimpleX (`/simplex`)
|
||||
- Hero, 3 etapes, 8 features, tableau comparatif (SimpleX vs Signal vs WhatsApp vs Matrix), cas d'usage
|
||||
|
||||
### Global
|
||||
- Header fixe glassmorphism avec nav + liens Nostr/SimpleX
|
||||
- Footer avec credits CC BY-SA 4.0
|
||||
- Telechargement des donnees JSON / YAML
|
||||
- Infographie SVG/PDF telechargeable
|
||||
|
||||
## 12. FEATURES A AJOUTER
|
||||
|
||||
- [ ] **Bouton installer PWA** dans le header (detecter beforeinstallprompt)
|
||||
- [ ] **Indicateur offline** discret quand pas de connexion
|
||||
- [ ] **Marqueur "vous etes a jour"** en fin de catalogue (ethique attentionnelle)
|
||||
- [ ] **Raccourcis clavier** : `/` recherche, `?` aide, `j/k` navigation catalogue
|
||||
- [ ] **Preparation i18n** : structure de cles pour FR et créole (creole guadeloupeen)
|
||||
|
||||
## 13. STEPS D'IMPLEMENTATION (ordre)
|
||||
|
||||
1. **Init** : `npm create svelte@latest atlas-fediverse` → skeleton → TypeScript, ESLint, Prettier, Vitest
|
||||
2. **Config** : `svelte.config.js` avec `adapter-static`, prerender all routes
|
||||
3. **Styles** : Creer `src/styles/` (tokens, reset, base, layouts) + `app.css` avec `@layer`
|
||||
4. **Data** : Copier les fichiers de donnees JSON dans `src/lib/data/`, importer comme modules
|
||||
5. **Layout** : `+layout.svelte` avec Header + Footer, slot pour le contenu
|
||||
6. **Accueil** : Sections hero (sans shader d'abord), protocoles, timeline, catalogue
|
||||
7. **Graphe 3D** : Migrer NetworkGraph (Three.js + D3) — lazy loaded via `await import()`
|
||||
8. **Shader** : Migrer HeroShader (Three.js) — lazy loaded
|
||||
9. **Pages** : Nostr + SimpleX
|
||||
10. **PWA** : service-worker.ts, manifest.json, icons
|
||||
11. **Polish** : accessibilite, raccourcis clavier, reduced-motion, offline indicator
|
||||
12. **Build** : `npm run build` → verifiez les seuils perf (JS < 100Ko, page < 500Ko)
|
||||
|
||||
## 14. VERIFICATION FINALE (checklist doctrine OKI)
|
||||
|
||||
Avant de livrer, verifier :
|
||||
- [ ] Pas de Tailwind, pas de shadcn, pas de framework CSS — CSS natif + tokens uniquement
|
||||
- [ ] Svelte 5 runes utilises ($state, $derived, $effect) — pas de syntaxe Svelte 4
|
||||
- [ ] Service Worker fonctionnel, PWA installable, offline-first
|
||||
- [ ] JS initial < 100 Ko (sans Three.js qui est lazy loaded)
|
||||
- [ ] Page totale < 500 Ko
|
||||
- [ ] Contraste WCAG 2.2 AA verifie sur tous les textes
|
||||
- [ ] prefers-reduced-motion, prefers-color-scheme, prefers-contrast gérés
|
||||
- [ ] Navigation clavier complete
|
||||
- [ ] Cibles tactiles ≥ 48 px
|
||||
- [ ] Pas de scroll infini — pagination ou marqueur de fin
|
||||
- [ ] Polices auto-hebergees ≤ 2 fichiers WOFF2
|
||||
- [ ] Container queries pour le responsive des composants (pas seulement media queries)
|
||||
- [ ] Fonctionne a 50% de largeur d'ecran (multi-fenêtrage desktop)
|
||||
|
||||
---
|
||||
|
||||
## NOTES POUR KIMI CLI
|
||||
|
||||
- Le projet actuel est accessible sur https://vhgsr7e3fioam.kimi.page pour reference visuelle
|
||||
- Les donnees JSON sont telechargeables depuis https://vhgsr7e3fioam.kimi.page/data/fediverse.json
|
||||
- Le code source React actuel est dans `/mnt/agents/output/app/src/` (reference pour les donnees et la logique, mais PAS pour le style — tout le CSS est a recrire selon la doctrine)
|
||||
- Le graphe 3D et le shader sont les composants les plus complexes — les migrer en dernier
|
||||
- Priorite : fonctionnel + beau + performant + accessible, dans cet ordre
|
||||
- Ne pas hesiter a simplifier visuellement si necessaire pour respecter les budgets perf
|
||||