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.
This commit is contained in:
sucupira
2026-07-21 13:10:12 -04:00
commit 93c577c5d3
80 changed files with 54745 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1856
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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"
}
}
+86
View File
@@ -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))`);
+8
View File
@@ -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);
+74
View File
@@ -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 &amp; 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 «&nbsp;<strong>{search.trim()}</strong>&nbsp;»{/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&nbsp;: {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>
+120
View File
@@ -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"
}
]
File diff suppressed because it is too large Load Diff
@@ -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 lAtlas', () => {
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 lordre 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);
});
});
+52
View File
@@ -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"
]
}
}
File diff suppressed because it is too large Load Diff
@@ -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/"
}
]
File diff suppressed because it is too large Load Diff
+418
View File
@@ -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',
},
];
+66
View File
@@ -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[];
}
+69
View File
@@ -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 linterface'
}
};
+75
View File
@@ -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;
}
+8
View File
@@ -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
);
}
+51
View File
@@ -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';
}
+28
View File
@@ -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 />
+6
View File
@@ -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';
+17
View File
@@ -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>
+122
View File
@@ -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));
});
+116
View File
@@ -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);
}
}
}
+55
View File
@@ -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)
);
}
}
+73
View File
@@ -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;
}
}
+37
View File
@@ -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;
}
}
+9
View File
@@ -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);
+100
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
]
}
}
+22
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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/
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+17
View File
@@ -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

File diff suppressed because one or more lines are too long
@@ -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

+32
View File
@@ -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"
}
]
}
+21
View File
@@ -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;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+9
View File
@@ -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}']
}
});