chore: état initial avant refonte (atlas SvelteKit v4.0.0)
Snapshot de l'existant avant application du playbook OKI : background shader Three.js, carte graphe 3D, timeline statique.
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "atlas-fediverse",
|
||||
"version": "4.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && node scripts/postbuild-csp.mjs",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"private": "true",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/three": "^0.185.1",
|
||||
"svelte": "^5.56.6",
|
||||
"svelte-check": "^4.7.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Génère les fichiers de configuration CSP du déploiement statique :
|
||||
- build/nginx-csp.conf → snippet à inclure dans la config nginx de l'app
|
||||
YunoHost (cible de production : https://syel.o-k-i.net).
|
||||
- build/.htaccess → équivalent Apache (secours, ignoré par nginx).
|
||||
|
||||
Pourquoi : SvelteKit démarre l'hydratation via un script inline dans le
|
||||
HTML. Une CSP stricte sans « unsafe-inline » (ex. script-src 'self')
|
||||
bloque ce bootstrap → site visible mais non interactif. Les empreintes
|
||||
SHA-256 des scripts inline l'autorisent précisément, sans unsafe-inline.
|
||||
Régénéré à chaque build (les empreintes changent avec les chunks). */
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const buildDir = fileURLToPath(new URL('../build/', import.meta.url));
|
||||
|
||||
function* walkHtml(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) yield* walkHtml(full);
|
||||
else if (entry.endsWith('.html')) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const hashes = new Set();
|
||||
|
||||
for (const file of walkHtml(buildDir)) {
|
||||
const html = readFileSync(file, 'utf8');
|
||||
// Scripts inline uniquement (pas d'attribut src) — le navigateur empreinte
|
||||
// le contenu texte exact du bloc.
|
||||
for (const match of html.matchAll(/<script(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/g)) {
|
||||
const content = match[1];
|
||||
if (!content.trim()) continue;
|
||||
const digest = createHash('sha256').update(content, 'utf8').digest('base64');
|
||||
hashes.add(`'sha256-${digest}'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hashes.size === 0) {
|
||||
console.warn('postbuild-csp: aucun script inline trouvé — vérifier le build');
|
||||
}
|
||||
|
||||
// Politique sobre pour un site statique autohébergé, + empreintes du bootstrap.
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' ${[...hashes].sort().join(' ')}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self'",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"worker-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'"
|
||||
].join('; ');
|
||||
|
||||
const nginx = `# Généré par scripts/postbuild-csp.mjs — ne pas éditer à la main.
|
||||
# À inclure dans la config nginx de l'app YunoHost, dans le bloc location
|
||||
# servant les fichiers statiques, ex. :
|
||||
# location / {
|
||||
# alias /var/www/<app>/;
|
||||
# include /var/www/<app>/nginx-csp.conf;
|
||||
# try_files $uri $uri/ =404;
|
||||
# }
|
||||
# Note nginx : un add_header au niveau le plus précis REMPLACE les add_header
|
||||
# hérités des niveaux supérieurs — une éventuelle CSP globale est neutralisée.
|
||||
# Régénéré à chaque build : le ré-uploader avec le reste du contenu de build/.
|
||||
add_header Content-Security-Policy "${csp}" always;
|
||||
`;
|
||||
|
||||
const htaccess = `# Généré par scripts/postbuild-csp.mjs — ne pas éditer à la main.
|
||||
# Équivalent Apache du snippet nginx (secours uniquement — nginx l'ignore).
|
||||
<IfModule mod_headers.c>
|
||||
Header unset Content-Security-Policy
|
||||
Header always unset Content-Security-Policy
|
||||
Header set Content-Security-Policy "${csp}"
|
||||
</IfModule>
|
||||
`;
|
||||
|
||||
writeFileSync(join(buildDir, 'nginx-csp.conf'), nginx);
|
||||
writeFileSync(join(buildDir, '.htaccess'), htaccess);
|
||||
console.log(`postbuild-csp: nginx-csp.conf + .htaccess écrits (${hashes.size} empreinte(s))`);
|
||||
@@ -0,0 +1,8 @@
|
||||
/* app.css — point d'entrée des styles, ordre de cascade explicite */
|
||||
@layer reset, tokens, base, layouts, components, utilities;
|
||||
|
||||
@import './styles/reset.css' layer(reset);
|
||||
@import './styles/tokens.css' layer(tokens);
|
||||
@import './styles/themes/default.css' layer(tokens);
|
||||
@import './styles/base.css' layer(base);
|
||||
@import './styles/layouts.css' layer(layouts);
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Le Grand Atlas du Fédiverse — 2026</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé."
|
||||
/>
|
||||
<meta name="theme-color" content="#001219" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/icons/logo.svg" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/space-grotesk-var.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/inter-var.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<style>
|
||||
/* 2 fichiers WOFF2 variables, auto-hébergés, font-display: swap.
|
||||
Dans app.html pour bénéficier de %sveltekit.assets% (base /fediverse). */
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
src: url('%sveltekit.assets%/fonts/space-grotesk-var.woff2') format('woff2');
|
||||
font-weight: 300 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('%sveltekit.assets%/fonts/inter-var.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
<div
|
||||
id="boot-warning"
|
||||
hidden
|
||||
style="position: fixed; inset-inline: 1rem; inset-block-end: 1rem; z-index: 9999; padding: 1rem 1.25rem; border: 2px solid #f72585; border-radius: 1rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; font-size: 0.9375rem; line-height: 1.5; box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);"
|
||||
>
|
||||
<strong>L'application n'a pas pu démarrer.</strong>
|
||||
Si vous lisez ce fichier en local (<code>file://</code>), servez le dossier
|
||||
<code>build/</code> via HTTP (par exemple <code>npx serve build</code>) : les navigateurs
|
||||
bloquent les modules JavaScript sur <code>file://</code>. Sinon, videz les données du site
|
||||
dans votre navigateur puis rechargez la page.
|
||||
</div>
|
||||
<noscript>
|
||||
<div
|
||||
style="padding: 1rem 1.25rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; text-align: center;"
|
||||
>
|
||||
JavaScript est désactivé : le contenu de l'Atlas reste lisible, mais la carte 3D et les
|
||||
fenêtres de détails sont indisponibles.
|
||||
</div>
|
||||
</noscript>
|
||||
<script src="%sveltekit.assets%/boot-check.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { getLocale, LOCALES, setLocale, t } from '$lib/i18n/i18n.svelte';
|
||||
import type { Locale } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
const projectLinks = [
|
||||
{ href: `${base}/#map`, labelKey: 'footer.about' },
|
||||
{ href: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', labelKey: 'footer.license' },
|
||||
{ href: 'https://github.com/', labelKey: 'footer.source' },
|
||||
{ href: `${base}/data/fediverse.json`, labelKey: 'footer.api' }
|
||||
] as const;
|
||||
|
||||
const resourceLinks = [
|
||||
{ href: 'https://activitypub.rocks/', label: 'ActivityPub.rocks' },
|
||||
{ href: 'https://fedidb.org/', label: 'FediDB' },
|
||||
{ href: 'https://jointhefediverse.net/', label: 'Join the Fediverse' },
|
||||
{ href: 'https://fediverse.info/', label: 'Fediverse.info' }
|
||||
];
|
||||
|
||||
const communityLinks = [
|
||||
{ href: 'https://joinmastodon.org/fr', label: 'Mastodon' },
|
||||
{ href: 'https://join-lemmy.org/', label: 'Lemmy' },
|
||||
{ href: 'https://joinpeertube.org/', label: 'PeerTube' },
|
||||
{ href: 'https://pixelfed.org/', label: 'PixelFed' }
|
||||
];
|
||||
|
||||
const downloads = [
|
||||
{ href: `${base}/data/fediverse.json`, label: 'JSON' },
|
||||
{ href: `${base}/data/fediverse.yaml`, label: 'YAML' },
|
||||
{ href: `${base}/infographie-fediverse-2026.svg`, label: 'Infographie SVG' },
|
||||
{ href: `${base}/infographie-fediverse-2026.pdf`, label: 'Infographie PDF' }
|
||||
];
|
||||
|
||||
const currentLocale = $derived(getLocale());
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<div class="center stack footer-stack">
|
||||
<div class="footer-grid">
|
||||
<div class="stack brand-col">
|
||||
<a href="{base}/" class="brand" aria-label={t('header.home')}>
|
||||
<img src="{base}/icons/logo.svg" alt="" width="40" height="40" />
|
||||
<span class="brand-name">Le Grand Atlas du Fédiverse</span>
|
||||
</a>
|
||||
<p class="muted">{t('footer.baseline')}</p>
|
||||
<p class="muted small">Version 4.0 · 2026 · {t('footer.madeIn')}</p>
|
||||
|
||||
<div class="locale-switcher" role="group" aria-label={t('footer.localeLabel')}>
|
||||
{#each LOCALES as locale (locale.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="locale-btn"
|
||||
aria-pressed={currentLocale === locale.id}
|
||||
onclick={() => setLocale(locale.id as Locale)}
|
||||
>
|
||||
{locale.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.project')}>
|
||||
<h3>{t('footer.project')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each projectLinks as link (link.href)}
|
||||
<li>
|
||||
<a href={link.href} {...link.href.startsWith('http') ? { rel: 'external' } : {}}>
|
||||
{t(link.labelKey)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.resources')}>
|
||||
<h3>{t('footer.resources')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each resourceLinks as link (link.href)}
|
||||
<li><a href={link.href} rel="external">{link.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.community')}>
|
||||
<h3>{t('footer.community')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each communityLinks as link (link.href)}
|
||||
<li><a href={link.href} rel="external">{link.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="downloads cluster" aria-label="Téléchargements">
|
||||
{#each downloads as dl (dl.href)}
|
||||
<a href={dl.href} class="download-chip" download>{dl.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bottom-bar stack">
|
||||
<p class="small muted">{t('footer.rights')}</p>
|
||||
<p class="small muted">{t('footer.credits')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
footer {
|
||||
background: var(--abyss);
|
||||
border-block-start: 1px solid var(--border);
|
||||
padding-block: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.footer-stack {
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-2);
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.brand-col {
|
||||
--stack-gap: var(--space-3);
|
||||
max-inline-size: 34rem;
|
||||
}
|
||||
|
||||
.link-col {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.link-col ul {
|
||||
--stack-gap: var(--space-1);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.link-col a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: 2rem;
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-col a:hover {
|
||||
color: var(--accent-2-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.locale-switcher {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
|
||||
.locale-btn {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink-soft);
|
||||
font-weight: 500;
|
||||
transition:
|
||||
color var(--duration-1) var(--ease-out),
|
||||
border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.locale-btn:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.locale-btn[aria-pressed='true'] {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.downloads {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.download-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.download-chip:hover {
|
||||
color: var(--accent-2-text);
|
||||
border-color: var(--accent-2-text);
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
--stack-gap: var(--space-1);
|
||||
border-block-start: 1px solid var(--border);
|
||||
padding-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { base } from '$app/paths';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { canInstall, promptInstall } from '$lib/pwa.svelte';
|
||||
|
||||
let scrolled = $state(false);
|
||||
let hidden = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuButton = $state<HTMLButtonElement | null>(null);
|
||||
let mobileNav = $state<HTMLElement | null>(null);
|
||||
|
||||
const anchors = [
|
||||
{ href: `${base}/#map`, key: 'nav.map' },
|
||||
{ href: `${base}/#protocols`, key: 'nav.protocols' },
|
||||
{ href: `${base}/#timeline`, key: 'nav.timeline' },
|
||||
{ href: `${base}/#catalog`, key: 'nav.catalog' }
|
||||
] as const;
|
||||
|
||||
// Slash final : évite la redirection 301 d'Apache (DirectorySlash)
|
||||
const routes = [
|
||||
{ href: `${base}/nostr/`, key: 'nav.nostr', accent: 'var(--accent-3)' },
|
||||
{ href: `${base}/simplex/`, key: 'nav.simplex', accent: 'var(--accent-4)' }
|
||||
] as const;
|
||||
|
||||
const pathname: string = $derived(page.url.pathname);
|
||||
|
||||
onMount(() => {
|
||||
let lastY = 0;
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY;
|
||||
scrolled = y > 80;
|
||||
// Auto-masquage : disparaît en défilant vers le bas (lecture),
|
||||
// réapparaît dès que l'utilisateur remonte. Jamais masqué en haut
|
||||
// de page ni quand le menu mobile est ouvert.
|
||||
if (y > 160 && y > lastY) {
|
||||
hidden = true;
|
||||
menuOpen = false;
|
||||
} else if (y < lastY || y <= 160) {
|
||||
hidden = false;
|
||||
}
|
||||
lastY = y;
|
||||
};
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
menuOpen = false;
|
||||
menuButton?.focus();
|
||||
}
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
// Ferme le menu mobile à chaque navigation
|
||||
$effect(() => {
|
||||
pathname;
|
||||
menuOpen = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
mobileNav?.querySelector<HTMLElement>('a, button')?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class:scrolled class:hidden>
|
||||
<div class="header-inner center">
|
||||
<a href="{base}/" class="brand" aria-label={t('header.home')}>
|
||||
<img src="{base}/icons/logo.svg" alt="" width="34" height="34" />
|
||||
<span class="wordmark">Atlas</span>
|
||||
</a>
|
||||
|
||||
<nav class="desktop-nav" aria-label={t('nav.main')}>
|
||||
<ul class="cluster" role="list">
|
||||
{#each anchors as link (link.href)}
|
||||
<li><a href={link.href} class="nav-link">{t(link.key)}</a></li>
|
||||
{/each}
|
||||
{#each routes as route (route.href)}
|
||||
<li>
|
||||
<a
|
||||
href={route.href}
|
||||
class="nav-link route-link"
|
||||
style:--link-accent={route.accent}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
>
|
||||
{ t(route.key)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="actions cluster">
|
||||
{#if canInstall()}
|
||||
<button type="button" class="install-btn" onclick={() => promptInstall()}>
|
||||
{t('header.install')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
bind:this={menuButton}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={menuOpen ? t('nav.closeMenu') : t('nav.openMenu')}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{#if menuOpen}
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
class="mobile-nav"
|
||||
aria-label={t('nav.main')}
|
||||
bind:this={mobileNav}
|
||||
>
|
||||
<ul class="stack" role="list">
|
||||
{#each anchors as link (link.href)}
|
||||
<li><a href={link.href} class="mobile-link">{t(link.key)}</a></li>
|
||||
{/each}
|
||||
{#each routes as route (route.href)}
|
||||
<li>
|
||||
<a
|
||||
href={route.href}
|
||||
class="mobile-link route-link"
|
||||
style:--link-accent={route.accent}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
>
|
||||
{t(route.key)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{#if canInstall()}
|
||||
<li>
|
||||
<button type="button" class="mobile-link install-mobile" onclick={() => promptInstall()}>
|
||||
{t('header.install')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
header {
|
||||
position: fixed;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 50;
|
||||
block-size: var(--header-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
transition:
|
||||
transform var(--duration-2) var(--ease-out),
|
||||
background-color var(--duration-2) var(--ease-out),
|
||||
border-color var(--duration-2) var(--ease-out);
|
||||
border-block-end: 1px solid transparent;
|
||||
}
|
||||
|
||||
header.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
header.scrolled {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-block-end-color: var(--glass-border);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 700;
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.route-link {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.route-link:hover,
|
||||
.route-link[aria-current='page'] {
|
||||
color: var(--link-accent);
|
||||
border-color: var(--link-accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-accent);
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
position: fixed;
|
||||
inset-block-start: var(--header-h);
|
||||
inset-inline: 0;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-block-end: 1px solid var(--glass-border);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-nav ul {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-1);
|
||||
font-weight: 500;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.mobile-link:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.install-mobile {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.desktop-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
/* HeroSection — plein viewport, shader océan en fond, titre + CTA. */
|
||||
import { stats } from '$lib/data';
|
||||
import HeroShader from './HeroShader.svelte';
|
||||
</script>
|
||||
|
||||
<section id="hero" aria-labelledby="hero-title">
|
||||
<HeroShader />
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<div class="content center stack">
|
||||
<h1 id="hero-title">
|
||||
Le réseau social,<br />
|
||||
<span class="gradient">sans les chaînes</span>
|
||||
</h1>
|
||||
|
||||
<p class="lede">
|
||||
Explorez le Fédiverse : une cartographie visuelle des
|
||||
<strong>{stats.totalSoftwares} logiciels</strong>
|
||||
qui redéfinissent la communication en ligne.
|
||||
</p>
|
||||
|
||||
<a href="#map" class="cta">
|
||||
Plonger dans la carte
|
||||
<svg
|
||||
class="chevron"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="scroll-indicator" aria-hidden="true">
|
||||
<span class="scroll-label">Scroll</span>
|
||||
<span class="scroll-line"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
/* Contexte sombre local : light-dark() bascule en mode sombre ici,
|
||||
quel que soit le thème de la page. */
|
||||
color-scheme: dark;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 100svh;
|
||||
overflow: hidden;
|
||||
background: var(--abyss);
|
||||
color: var(--on-accent);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
/* Voile de lisibilité : garantit le contraste du texte sur le shader */
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--abyss) 45%, transparent),
|
||||
color-mix(in oklab, var(--abyss) 25%, transparent) 45%,
|
||||
var(--abyss)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
--stack-gap: var(--space-4);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(var(--text-4), 4vw + 1.5rem, 4.5rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-inline-size: var(--measure);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: var(--text-small);
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-accent);
|
||||
transition: transform var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
animation: bounce 1.6s var(--ease-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
inset-block-end: var(--space-4);
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.scroll-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.scroll-line {
|
||||
inline-size: 1px;
|
||||
block-size: var(--space-5);
|
||||
background: linear-gradient(180deg, var(--ink-soft), transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chevron {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
/* HeroShader — océan WebGL (Three.js, chargé paresseusement côté client).
|
||||
Remplit son parent en absolu ; purement décoratif (aria-hidden). */
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/* Palette cosinus type Iñigo Quilez : a + b*cos(6.28318*(c*t+d)) */
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 palette(in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d) {
|
||||
return a + b * cos(6.28318 * (c * t + d));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = (vUv - 0.5) * 2.0;
|
||||
uv.x *= u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv0 = uv;
|
||||
vec3 finalColor = vec3(0.0);
|
||||
|
||||
for (float i = 0.0; i < 3.0; i++) {
|
||||
uv = fract(uv * 1.5) - 0.5;
|
||||
|
||||
float d = length(uv) * exp(-length(uv0));
|
||||
|
||||
vec3 col = palette(length(uv0) + i * 0.4 + u_time * 0.4,
|
||||
vec3(0.5, 0.5, 0.9),
|
||||
vec3(0.5, 0.5, 0.4),
|
||||
vec3(1.0, 1.0, 0.9),
|
||||
vec3(0.0, 0.1, 0.2));
|
||||
|
||||
d = sin(d * 8.0 + u_time) / 8.0;
|
||||
d = abs(d);
|
||||
d = pow(0.01 / d, 1.2);
|
||||
|
||||
finalColor += col * d;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
(async () => {
|
||||
// Import paresseux obligatoire : rien de Three au top-level (SSR).
|
||||
const THREE = await import('three');
|
||||
if (disposed || !container) return;
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const width = container.offsetWidth || window.innerWidth;
|
||||
const height = container.offsetHeight || window.innerHeight;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 2000);
|
||||
camera.position.z = 1;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.domElement.style.width = '100%';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const uniforms = {
|
||||
u_time: { value: 0.0 },
|
||||
u_resolution: { value: new THREE.Vector2(width, height) }
|
||||
};
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(2, 2);
|
||||
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
uniforms.u_time.value += 0.008;
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (reduceMotion) {
|
||||
// Mouvement réduit : une seule frame statique (u_time fixe), pas de boucle.
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
// Frugalité : la boucle ne tourne que si le canvas est visible à l'écran
|
||||
// ET si l'onglet est actif.
|
||||
let inView = false;
|
||||
let pageVisible = !document.hidden;
|
||||
|
||||
const stop = () => {
|
||||
if (raf !== 0) {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
const sync = () => {
|
||||
if (inView && pageVisible) {
|
||||
if (raf === 0) raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
inView = entries[0]?.isIntersecting ?? false;
|
||||
sync();
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
observer.observe(container);
|
||||
|
||||
const onVisibility = () => {
|
||||
pageVisible = !document.hidden;
|
||||
sync();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = container.offsetHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
uniforms.u_resolution.value.set(w, h);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
cleanup = () => {
|
||||
stop();
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Chemin reduced-motion : pas d'observateurs d'animation, juste le resize.
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = container.offsetHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
uniforms.u_resolution.value.set(w, h);
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
cleanup = () => {
|
||||
resizeObserver.disconnect();
|
||||
renderer.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shader" bind:this={container} aria-hidden="true"></div>
|
||||
|
||||
<style>
|
||||
.shader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,797 @@
|
||||
<script lang="ts">
|
||||
/* Graphe 3D de l'écosystème — Three.js + d3-force.
|
||||
Les deux libs sont chargées paresseusement (await import dans onMount) :
|
||||
aucun octet de Three/D3 dans le bundle initial. Topologie déterministe
|
||||
issue de networkData (network.json pré-calculé, pas de Math.random de liens). */
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { categories, networkData, softwares } from '$lib/data';
|
||||
import type { NetworkNode, Software } from '$lib/data';
|
||||
import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from 'd3-force';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
type SimNode = NetworkNode & SimulationNodeDatum;
|
||||
type SimLink = SimulationLinkDatum<SimNode> & { type: 'strong' | 'weak' };
|
||||
|
||||
interface TooltipState {
|
||||
node: NetworkNode;
|
||||
software?: Software;
|
||||
x: number;
|
||||
y: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
let viewport = $state<HTMLDivElement | null>(null);
|
||||
// SSR / sans-JS : booting reste false → message de repli lisible (pas de
|
||||
// squelette éternel). onMount le passe à true avant de charger les chunks.
|
||||
let booting = $state(false);
|
||||
let loading = $state(true);
|
||||
let unavailable = $state(false);
|
||||
let tooltip = $state<TooltipState | null>(null);
|
||||
|
||||
let filters = $state<Record<string, boolean>>(
|
||||
Object.fromEntries(categories.map((c) => [c.id, true]))
|
||||
);
|
||||
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
|
||||
|
||||
// Assigné par onMount une fois les libs chargées.
|
||||
let rebuildGraph: (() => void) | null = null;
|
||||
|
||||
function toggleFilter(categoryId: string) {
|
||||
filters = { ...filters, [categoryId]: !filters[categoryId] };
|
||||
tooltip = null;
|
||||
rebuildGraph?.();
|
||||
}
|
||||
|
||||
function closeTooltip() {
|
||||
tooltip = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!viewport) return;
|
||||
booting = true;
|
||||
const container = viewport;
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let cancelled = false;
|
||||
let disposeGraph: (() => void) | null = null;
|
||||
let setPaused: ((paused: boolean) => void) | null = null;
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') tooltip = null;
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
|
||||
let ioPaused = false;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
ioPaused = !entries[0]?.isIntersecting;
|
||||
setPaused?.(ioPaused || document.hidden);
|
||||
},
|
||||
{ threshold: 0.05 }
|
||||
);
|
||||
const onVisibility = () => setPaused?.(document.hidden || ioPaused);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const THREE = await import('three');
|
||||
const d3 = await import('d3-force');
|
||||
if (cancelled) return;
|
||||
|
||||
// ————— Helpers couleurs : var(--…) → rgb utilisable par Three.js.
|
||||
// THREE.Color ne comprend ni oklch() ni light-dark() : conversion manuelle.
|
||||
const oklchToSrgb = (L: number, C: number, H: number): [number, number, number] => {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
const a = C * Math.cos(hRad);
|
||||
const b = C * Math.sin(hRad);
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
||||
const l = l_ ** 3;
|
||||
const m = m_ ** 3;
|
||||
const s = s_ ** 3;
|
||||
const to255 = (v: number): number => {
|
||||
const c = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
return Math.round(Math.min(Math.max(c, 0), 1) * 255);
|
||||
};
|
||||
return [
|
||||
to255(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
to255(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
to255(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
|
||||
];
|
||||
};
|
||||
|
||||
const toRgb = (color: string, fallback: string): string => {
|
||||
const oklchMatch = color.match(
|
||||
/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*[\d.]+%?)?\s*\)/
|
||||
);
|
||||
if (oklchMatch) {
|
||||
const lightness = parseFloat(oklchMatch[1]) / (oklchMatch[2] ? 100 : 1);
|
||||
const [r, g, b] = oklchToSrgb(
|
||||
lightness,
|
||||
parseFloat(oklchMatch[3]),
|
||||
parseFloat(oklchMatch[4])
|
||||
);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
if (/^(#|rgb)/.test(color)) return color;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const cssColor = (varName: string, fallback: string): string => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `var(${varName}, ${fallback})`;
|
||||
probe.style.display = 'none';
|
||||
container.appendChild(probe);
|
||||
const computed = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return toRgb(computed, fallback);
|
||||
};
|
||||
|
||||
const inkColor = cssColor('--ink', '#e8eef4');
|
||||
const lineColor = cssColor('--border-strong', '#8a97a8');
|
||||
|
||||
// ————— Scène persistante —————
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
container.offsetWidth / Math.max(container.offsetHeight, 1),
|
||||
0.1,
|
||||
2000
|
||||
);
|
||||
camera.position.z = 100;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(container.offsetWidth, container.offsetHeight);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
|
||||
const directional = new THREE.DirectionalLight(0xffffff, 1.4);
|
||||
directional.position.set(40, 60, 80);
|
||||
scene.add(directional);
|
||||
|
||||
const graphGroup = new THREE.Group();
|
||||
scene.add(graphGroup);
|
||||
|
||||
// ————— État du sous-graphe courant —————
|
||||
let instancedMesh: InstanceType<typeof THREE.InstancedMesh> | null = null;
|
||||
let simulation: Simulation<SimNode, undefined> | null = null;
|
||||
let currentNodes: SimNode[] = [];
|
||||
const labelSprites = new Map<string, InstanceType<typeof THREE.Sprite>>();
|
||||
const linkLines: InstanceType<typeof THREE.Line>[] = [];
|
||||
const nodePositions = new Map<string, InstanceType<typeof THREE.Vector3>>();
|
||||
|
||||
const teardownSubgraph = () => {
|
||||
simulation?.stop();
|
||||
simulation = null;
|
||||
graphGroup.traverse((obj) => {
|
||||
if (obj instanceof THREE.InstancedMesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as import('three').Material).dispose();
|
||||
} else if (obj instanceof THREE.Sprite) {
|
||||
obj.material.map?.dispose();
|
||||
obj.material.dispose();
|
||||
} else if (obj instanceof THREE.Line) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as import('three').Material).dispose();
|
||||
}
|
||||
});
|
||||
graphGroup.clear();
|
||||
labelSprites.clear();
|
||||
linkLines.length = 0;
|
||||
nodePositions.clear();
|
||||
instancedMesh = null;
|
||||
currentNodes = [];
|
||||
};
|
||||
|
||||
const applyPositions = (dummy: InstanceType<typeof THREE.Object3D>) => {
|
||||
const mesh = instancedMesh;
|
||||
if (!mesh) return;
|
||||
currentNodes.forEach((node, i) => {
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
const z = node.z ?? 0;
|
||||
const scale = (node.val || 5) * 0.4;
|
||||
|
||||
dummy.position.set(x, y, z);
|
||||
dummy.scale.set(scale, scale, scale);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(i, dummy.matrix);
|
||||
|
||||
labelSprites.get(node.id)?.position.set(x, y + scale + 2, z);
|
||||
nodePositions.set(node.id, new THREE.Vector3(x, y, z));
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
|
||||
for (const line of linkLines) {
|
||||
const src = nodePositions.get(line.userData.source as string);
|
||||
const tgt = nodePositions.get(line.userData.target as string);
|
||||
if (!src || !tgt) continue;
|
||||
const attr = line.geometry.getAttribute(
|
||||
'position'
|
||||
) as import('three').BufferAttribute;
|
||||
const arr = attr.array as Float32Array;
|
||||
arr[0] = src.x; arr[1] = src.y; arr[2] = src.z;
|
||||
arr[3] = tgt.x; arr[4] = tgt.y; arr[5] = tgt.z;
|
||||
attr.needsUpdate = true;
|
||||
}
|
||||
};
|
||||
|
||||
const dummy = new THREE.Object3D();
|
||||
|
||||
const buildSubgraph = () => {
|
||||
teardownSubgraph();
|
||||
tooltip = null;
|
||||
|
||||
const activeCats = new Set(
|
||||
Object.entries(filters)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
);
|
||||
|
||||
currentNodes = networkData.nodes
|
||||
.filter((n) => activeCats.has(n.category))
|
||||
.map((n) => ({
|
||||
...n,
|
||||
x: (Math.random() - 0.5) * 60,
|
||||
y: (Math.random() - 0.5) * 60,
|
||||
z: (Math.random() - 0.5) * 30
|
||||
}));
|
||||
|
||||
if (currentNodes.length === 0) {
|
||||
renderer.render(scene, camera);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeIds = new Set(currentNodes.map((n) => n.id));
|
||||
const links: SimLink[] = networkData.links
|
||||
.filter(
|
||||
(l) =>
|
||||
nodeIds.has(l.source as string) && nodeIds.has(l.target as string)
|
||||
)
|
||||
.map((l) => ({
|
||||
source: l.source as string,
|
||||
target: l.target as string,
|
||||
type: l.type
|
||||
}));
|
||||
|
||||
// Nœuds (instanciés)
|
||||
const geometry = new THREE.SphereGeometry(1, 16, 16);
|
||||
const material = new THREE.MeshPhysicalMaterial({
|
||||
color: 0xffffff,
|
||||
metalness: 0.1,
|
||||
roughness: 0.3,
|
||||
clearcoat: 1.0,
|
||||
clearcoatRoughness: 0.1
|
||||
});
|
||||
instancedMesh = new THREE.InstancedMesh(geometry, material, currentNodes.length);
|
||||
instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
currentNodes.forEach((node, i) => {
|
||||
instancedMesh!.setColorAt(i, new THREE.Color(node.color || '#888888'));
|
||||
});
|
||||
if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true;
|
||||
graphGroup.add(instancedMesh);
|
||||
|
||||
// Labels (un canvas par sprite — le partage de canvas entre
|
||||
// CanvasTexture afficherait le dernier label partout)
|
||||
for (const node of currentNodes) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.font = '500 24px "Space Grotesk", sans-serif';
|
||||
ctx.fillStyle = inkColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(node.id, 128, 32);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const spriteMaterial = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.85
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.scale.set(12, 3, 1);
|
||||
graphGroup.add(sprite);
|
||||
labelSprites.set(node.id, sprite);
|
||||
}
|
||||
|
||||
// Liens
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(lineColor),
|
||||
transparent: true,
|
||||
opacity: 0.3
|
||||
});
|
||||
for (const link of links) {
|
||||
const positions = new Float32Array(6);
|
||||
const lineGeometry = new THREE.BufferGeometry();
|
||||
lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial);
|
||||
line.userData = {
|
||||
source: link.source,
|
||||
target: link.target
|
||||
};
|
||||
graphGroup.add(line);
|
||||
linkLines.push(line);
|
||||
}
|
||||
|
||||
// Simulation (2D, z statique — parité avec la version React)
|
||||
simulation = d3
|
||||
.forceSimulation<SimNode>(currentNodes)
|
||||
.force(
|
||||
'link',
|
||||
d3
|
||||
.forceLink<SimNode, SimLink>(links)
|
||||
.id((d) => d.id)
|
||||
.distance((d) => (d.type === 'strong' ? 15 : 35))
|
||||
)
|
||||
.force('charge', d3.forceManyBody().strength(-100))
|
||||
.force('center', d3.forceCenter(0, 0))
|
||||
.force(
|
||||
'collide',
|
||||
d3
|
||||
.forceCollide<SimNode>()
|
||||
.radius((d) => (d.group === 1 ? 6 : 3) + 2)
|
||||
.iterations(2)
|
||||
)
|
||||
.force('x', d3.forceX(0).strength(0.04))
|
||||
.force('y', d3.forceY(0).strength(0.04))
|
||||
.alphaDecay(0.02);
|
||||
|
||||
if (reducedMotion) {
|
||||
// Convergence synchrone, une seule frame rendue.
|
||||
simulation.stop();
|
||||
for (let i = 0; i < 300; i++) simulation.tick();
|
||||
applyPositions(dummy);
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
simulation.on('tick', () => applyPositions(dummy));
|
||||
}
|
||||
};
|
||||
|
||||
// ————— Interactions —————
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
|
||||
const pick = (clientX: number, clientY: number): SimNode | null => {
|
||||
if (!instancedMesh) return null;
|
||||
const rect = container.getBoundingClientRect();
|
||||
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hits = raycaster.intersectObject(instancedMesh);
|
||||
const id = hits[0]?.instanceId;
|
||||
return id !== undefined ? (currentNodes[id] ?? null) : null;
|
||||
};
|
||||
|
||||
const showTooltip = (node: SimNode, clientX: number, clientY: number, pinned: boolean) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
tooltip = {
|
||||
node,
|
||||
software: softwares.find((s) => s.name === node.id),
|
||||
x: Math.min(clientX - rect.left + 20, rect.width - 280),
|
||||
y: Math.min(clientY - rect.top + 20, rect.height - 220),
|
||||
pinned
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'mouse' || tooltip?.pinned) return;
|
||||
const node = pick(event.clientX, event.clientY);
|
||||
if (node) {
|
||||
showTooltip(node, event.clientX, event.clientY, false);
|
||||
container.style.cursor = 'pointer';
|
||||
} else {
|
||||
tooltip = null;
|
||||
container.style.cursor = 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const node = pick(event.clientX, event.clientY);
|
||||
if (node) {
|
||||
showTooltip(node, event.clientX, event.clientY, true);
|
||||
} else {
|
||||
tooltip = null;
|
||||
}
|
||||
};
|
||||
|
||||
// ————— Boucle de rendu & pause —————
|
||||
let rafId = 0;
|
||||
let paused = false;
|
||||
|
||||
const animate = () => {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
setPaused = (next: boolean) => {
|
||||
if (reducedMotion) return;
|
||||
if (next === paused) return;
|
||||
paused = next;
|
||||
if (paused) {
|
||||
cancelAnimationFrame(rafId);
|
||||
} else {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = Math.max(container.offsetHeight, 1);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (reducedMotion || paused) renderer.render(scene, camera);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
container.addEventListener('pointermove', onPointerMove);
|
||||
container.addEventListener('click', onClick);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
buildSubgraph();
|
||||
rebuildGraph = buildSubgraph;
|
||||
loading = false;
|
||||
|
||||
if (!reducedMotion) animate();
|
||||
|
||||
disposeGraph = () => {
|
||||
setPaused = null;
|
||||
rebuildGraph = null;
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
container.removeEventListener('pointermove', onPointerMove);
|
||||
container.removeEventListener('click', onClick);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
teardownSubgraph();
|
||||
renderer.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
unavailable = true;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
io.observe(container);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
io.disconnect();
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
disposeGraph?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="map" aria-labelledby="map-title">
|
||||
<div class="center section-head stack">
|
||||
<p class="kicker">Cartographie</p>
|
||||
<h2 id="map-title">L'Écosystème</h2>
|
||||
<p class="subtitle">
|
||||
{activeCount} catégories actives · Survolez ou touchez un nœud pour plus d'infos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="graph-wrap center">
|
||||
<div
|
||||
class="viewport"
|
||||
bind:this={viewport}
|
||||
role="img"
|
||||
aria-label="Graphe 3D des 105 logiciels du Fédiverse, colorés par catégorie. Le détail de chaque logiciel est aussi disponible dans le catalogue."
|
||||
>
|
||||
{#if !booting}
|
||||
<div class="fallback stack">
|
||||
<p>La carte 3D interactive nécessite JavaScript.</p>
|
||||
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="skeleton" role="status">
|
||||
<span>Chargement de la carte…</span>
|
||||
</div>
|
||||
{:else if unavailable}
|
||||
<div class="fallback stack">
|
||||
<p>La carte 3D n'est pas disponible dans ce navigateur.</p>
|
||||
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<aside class="filters" aria-label="Filtres par catégorie">
|
||||
<h3>Filtres</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each categories as cat (cat.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
class:inactive={!filters[cat.id]}
|
||||
aria-pressed={filters[cat.id]}
|
||||
style:--cat-color={cat.color}
|
||||
onclick={() => toggleFilter(cat.id)}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="cat-name">{cat.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{#if tooltip}
|
||||
<div
|
||||
class="tooltip"
|
||||
style:left="{tooltip.x}px"
|
||||
style:top="{tooltip.y}px"
|
||||
role={tooltip.pinned ? 'dialog' : 'status'}
|
||||
aria-label={tooltip.pinned ? `Détails de ${tooltip.node.id}` : undefined}
|
||||
>
|
||||
<div class="tooltip-head">
|
||||
<span class="dot glow" style:--cat-color={tooltip.node.color} aria-hidden="true"></span>
|
||||
<h4>{tooltip.node.id}</h4>
|
||||
{#if tooltip.pinned}
|
||||
<button type="button" class="tooltip-close" onclick={closeTooltip} aria-label={t('common.close')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="tooltip-desc">{tooltip.node.description}</p>
|
||||
<dl class="tooltip-meta mono">
|
||||
<div><dt>Licence</dt><dd>{tooltip.node.licence}</dd></div>
|
||||
{#if tooltip.software}
|
||||
<div><dt>Langage</dt><dd>{tooltip.software.language}</dd></div>
|
||||
{/if}
|
||||
<div><dt>État</dt><dd>{tooltip.node.etat}</dd></div>
|
||||
<div><dt>ActivityPub</dt><dd>{tooltip.node.compatibiliteAP}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--surface);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
--stack-gap: var(--space-2);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.graph-wrap {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
block-size: clamp(30rem, 85vh, 56rem);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fallback-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ————— Panneau de filtres ————— */
|
||||
.filters {
|
||||
margin-block-start: var(--space-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.filters h3 {
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
--stack-gap: var(--space-1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-2);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.filter-btn .dot {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cat-color);
|
||||
box-shadow: 0 0 0.5rem var(--cat-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .dot {
|
||||
background: var(--muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .cat-name {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
@container (min-width: 56rem) {
|
||||
.filters {
|
||||
position: absolute;
|
||||
inset-block-start: var(--space-3);
|
||||
inset-inline-end: var(--space-3);
|
||||
margin-block-start: 0;
|
||||
max-inline-size: 15rem;
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— Tooltip / panneau détail ————— */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
max-inline-size: 17rem;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
box-shadow: var(--shadow-2);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.tooltip-head h4 {
|
||||
font-size: var(--text-1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dot.glow {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cat-color);
|
||||
box-shadow: 0 0 0.5rem var(--cat-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tooltip-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tooltip-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.tooltip-meta {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.tooltip-meta div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tooltip-meta dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tooltip-meta dd {
|
||||
color: var(--ink);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let online = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
online = navigator.onLine;
|
||||
const handleOnline = () => (online = true);
|
||||
const handleOffline = () => (online = false);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !online}
|
||||
<div class="offline-pill" role="status">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('offline.indicator')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.offline-pill {
|
||||
position: fixed;
|
||||
inset-block-end: var(--space-3);
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-small);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--status-maintenance);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
/* ProtocolSection — grille des 12 protocoles sur fond « data stream ». */
|
||||
import { protocols, type Protocol } from '$lib/data';
|
||||
import ProtocolStream from './ProtocolStream.svelte';
|
||||
|
||||
const TYPES: Record<Protocol['type'], { label: string; color: string }> = {
|
||||
fediverse: { label: 'Protocole du Fédiverse', color: 'var(--accent-4-text)' },
|
||||
connexe: { label: 'Protocole connexe', color: 'var(--status-maintenance)' },
|
||||
voisin: { label: 'Écosystème voisin', color: 'var(--accent-text)' }
|
||||
};
|
||||
|
||||
const TYPE_ORDER: Protocol['type'][] = ['fediverse', 'connexe', 'voisin'];
|
||||
|
||||
/* Couleur doublée par le texte du statut (jamais la couleur seule) */
|
||||
const STATUS_COLORS: Record<Protocol['statut'], string> = {
|
||||
Actif: 'var(--status-active)',
|
||||
'En développement': 'var(--status-experimental)',
|
||||
Historique: 'var(--status-historical)'
|
||||
};
|
||||
</script>
|
||||
|
||||
<section id="protocols" aria-labelledby="protocols-title">
|
||||
<ProtocolStream />
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<div class="content center stack">
|
||||
<header class="section-head stack">
|
||||
<p class="kicker">Standards & Protocoles</p>
|
||||
<h2 id="protocols-title">Les fondations du <span class="gradient">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
Comprendre les protocoles qui permettent la communication décentralisée entre les
|
||||
différentes plateformes.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid cards" role="list">
|
||||
{#each protocols as proto (proto.id)}
|
||||
<li>
|
||||
<article class="card" style:--type-color={TYPES[proto.type].color}>
|
||||
<div class="card-inner">
|
||||
<div class="card-media">
|
||||
<span class="icon-box">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if proto.id === 'activitypub'}
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
{:else if proto.id === 'activitystreams'}
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
|
||||
{:else if proto.id === 'webfinger'}
|
||||
<path d="M2 9.5a15 15 0 0 1 20 0" />
|
||||
<path d="M5 13a10 10 0 0 1 14 0" />
|
||||
<path d="M8.5 16.5a5 5 0 0 1 7 0" />
|
||||
<path d="M12 20h.01" />
|
||||
{:else if proto.id === 'httpsignatures'}
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
{:else if proto.id === 'nodeinfo'}
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" />
|
||||
<path d="M6 6h.01" />
|
||||
<path d="M6 18h.01" />
|
||||
{:else if proto.id === 'zot'}
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
{:else if proto.id === 'diaspora'}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
{:else if proto.id === 'ostatus'}
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
{:else if proto.id === 'matrix'}
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
{:else if proto.id === 'xmpp'}
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M4.93 19.07a10 10 0 0 1 0-14.14" />
|
||||
<path d="M7.76 16.24a6 6 0 0 1 0-8.49" />
|
||||
<path d="M16.24 7.76a6 6 0 0 1 0 8.49" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
{:else if proto.id === 'atproto'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="m10 13-2 2 2 2" />
|
||||
<path d="m14 17 2-2-2-2" />
|
||||
{:else if proto.id === 'nostr'}
|
||||
<circle cx="7.5" cy="15.5" r="5.5" />
|
||||
<path d="m21 2-9.6 9.6" />
|
||||
<path d="m15.5 7.5 3 3L22 7l-3-3" />
|
||||
{:else}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body stack">
|
||||
<div class="card-head cluster">
|
||||
<h3>{proto.name}</h3>
|
||||
<span class="badge">{TYPES[proto.type].label}</span>
|
||||
<span class="status" style:--status-color={STATUS_COLORS[proto.statut]}>
|
||||
<span class="dot" aria-hidden="true"></span>{proto.statut}
|
||||
</span>
|
||||
</div>
|
||||
<p class="description">{proto.description}</p>
|
||||
{#if proto.rfc}
|
||||
<span class="rfc">{proto.rfc}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ul class="legend cluster" role="list">
|
||||
{#each TYPE_ORDER as type (type)}
|
||||
<li class="cluster" style:--type-color={TYPES[type].color}>
|
||||
<span class="dot" aria-hidden="true"></span>{TYPES[type].label}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
/* Contexte sombre local : light-dark() bascule en mode sombre ici. */
|
||||
color-scheme: dark;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--ocean);
|
||||
color: var(--on-accent);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
/* Voile de lisibilité au-dessus du data stream */
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--ocean) 70%, transparent),
|
||||
color-mix(in oklab, var(--ocean) 55%, transparent)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
--stack-gap: var(--space-2);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent-2-text);
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-inline-size: var(--measure);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.cards {
|
||||
--grid-min: 20rem;
|
||||
--grid-gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
container-type: inline-size;
|
||||
block-size: 100%;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(0.5rem);
|
||||
-webkit-backdrop-filter: blur(0.5rem);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
transition:
|
||||
transform var(--duration-2) var(--ease-out),
|
||||
border-color var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--type-color);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* Container query : carte large → layout horizontal (icône à gauche) */
|
||||
@container (min-width: 28rem) {
|
||||
.card-inner {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.card-media {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--type-color);
|
||||
background: color-mix(in oklab, var(--type-color) 12%, transparent);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
--stack-gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--type-color);
|
||||
background: color-mix(in oklab, var(--type-color) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--type-color) 30%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--status-color, var(--type-color));
|
||||
box-shadow: 0 0 0.375rem var(--status-color, var(--type-color));
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.rfc {
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
background: color-mix(in oklab, var(--on-accent) 6%, transparent);
|
||||
border-radius: var(--radius-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.legend {
|
||||
--cluster-gap: var(--space-4);
|
||||
justify-content: center;
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.legend .dot {
|
||||
background: var(--type-color);
|
||||
box-shadow: 0 0 0.375rem var(--type-color);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
/* ProtocolStream — colonnes de « data stream » défilant en boucle,
|
||||
purement décoratives (aria-hidden). Aucune animation si mouvement réduit. */
|
||||
|
||||
const LINES = [
|
||||
'ActivityPub v1.0',
|
||||
'{ "type": "Create", "actor": "https://..." }',
|
||||
'W3C Recommendation',
|
||||
'WebFinger RFC 7033',
|
||||
'HTTP Signatures',
|
||||
'NodeInfo 2.0',
|
||||
'ActivityStreams 2.0',
|
||||
'application/activity+json',
|
||||
'followers_collection',
|
||||
'inbox → outbox',
|
||||
'FEP-8b32: Long Objects',
|
||||
'FEP-5624: Per-Event Push',
|
||||
'@user@instance.social',
|
||||
'fediverse:culture',
|
||||
'2026-01-14T09:30:00Z',
|
||||
'public_key: RSA 2048',
|
||||
'LD-Signatures',
|
||||
'content-type: application/ld+json',
|
||||
'as:Note, as:Article',
|
||||
'sharedInbox enabled'
|
||||
] as const;
|
||||
|
||||
/* Contenu doublé pour une boucle seamless (translateY(-50%) → 0) */
|
||||
const DOUBLED = [...LINES, ...LINES];
|
||||
|
||||
const COLUMNS = [
|
||||
{ left: '-10%', duration: '20s', delay: '0s' },
|
||||
{ left: '15%', duration: '28s', delay: '3s' },
|
||||
{ left: '40%', duration: '24s', delay: '7s' },
|
||||
{ left: '65%', duration: '32s', delay: '1s' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="stream" aria-hidden="true">
|
||||
{#each COLUMNS as col (col.left)}
|
||||
<div
|
||||
class="column"
|
||||
style:--left={col.left}
|
||||
style:--duration={col.duration}
|
||||
style:--delay={col.delay}
|
||||
>
|
||||
{#each DOUBLED as line, i (i)}
|
||||
<span class="line">{line}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stream {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: var(--left);
|
||||
inline-size: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--accent);
|
||||
opacity: 0.3;
|
||||
animation: stream-flow var(--duration) linear infinite;
|
||||
animation-delay: var(--delay);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.line {
|
||||
white-space: nowrap;
|
||||
padding-inline: var(--space-2);
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
@keyframes stream-flow {
|
||||
from {
|
||||
transform: translateY(-50%) skewX(-20deg);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0) skewX(-20deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.column {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { isTypingTarget } from '$lib/keyboard';
|
||||
|
||||
let open = $state(false);
|
||||
let dialogEl = $state<HTMLElement | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && open) {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
if (event.key === '?' && !isTypingTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
open = !open;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
dialogEl?.querySelector<HTMLElement>('button')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['/'], descKey: 'shortcuts.search' },
|
||||
{ keys: ['?'], descKey: 'shortcuts.help' },
|
||||
{ keys: ['j'], descKey: 'shortcuts.next' },
|
||||
{ keys: ['k'], descKey: 'shortcuts.prev' },
|
||||
{ keys: ['Échap'], descKey: 'shortcuts.close' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="backdrop"
|
||||
onclick={() => (open = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (open = false)}
|
||||
role="presentation"
|
||||
></div>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-title"
|
||||
bind:this={dialogEl}
|
||||
>
|
||||
<div class="dialog-header cluster">
|
||||
<h2 id="shortcuts-title">{t('shortcuts.title')}</h2>
|
||||
<button type="button" class="close-btn" onclick={() => (open = false)} aria-label={t('common.close')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="stack" role="list">
|
||||
{#each shortcuts as shortcut (shortcut.descKey)}
|
||||
<li class="shortcut-row">
|
||||
<span class="cluster keys">
|
||||
{#each shortcut.keys as key (key)}
|
||||
<kbd>{key}</kbd>
|
||||
{/each}
|
||||
</span>
|
||||
<span>{t(shortcut.descKey)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: oklch(10% 0.02 260 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
z-index: 90;
|
||||
inline-size: min(26rem, calc(100vw - 2 * var(--space-3)));
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
justify-content: space-between;
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.dialog ul {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: 2rem;
|
||||
min-block-size: 2rem;
|
||||
padding-inline: var(--space-2);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-block-end-width: 2px;
|
||||
border-radius: var(--radius-1);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,848 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { categoryById, ETATS, softwares, type Category, type Software } from '$lib/data';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { isTypingTarget } from '$lib/keyboard';
|
||||
|
||||
type StatusFilter = Software['etat'] | 'all';
|
||||
|
||||
const STORAGE_KEY = 'atlas-catalog-filters';
|
||||
|
||||
const statusColor: Record<Software['etat'], string> = {
|
||||
Actif: 'var(--status-active)',
|
||||
Maintenance: 'var(--status-maintenance)',
|
||||
Experimental: 'var(--status-experimental)',
|
||||
Historique: 'var(--status-historical)'
|
||||
};
|
||||
|
||||
const compatColor: Record<Software['compatibiliteAP'], string> = {
|
||||
Pleine: 'var(--compat-full)',
|
||||
Partielle: 'var(--compat-partial)',
|
||||
Extension: 'var(--compat-extension)',
|
||||
Aucune: 'var(--compat-none)',
|
||||
'En développement': 'var(--compat-none)'
|
||||
};
|
||||
|
||||
/** Choisit un texte contrasté (clair ou foncé) selon la luminance WCAG d'une couleur hex. */
|
||||
function contrastingInk(hex: string | undefined): string {
|
||||
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return 'var(--on-accent)';
|
||||
const channels = [1, 3, 5].map((start) => {
|
||||
const c = parseInt(hex.slice(start, start + 2), 16) / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
const luminance = 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
return luminance > 0.18 ? 'oklch(22% 0.02 260)' : 'var(--on-accent)';
|
||||
}
|
||||
|
||||
function categoryOf(software: Software): Category | undefined {
|
||||
return categoryById.get(software.category);
|
||||
}
|
||||
|
||||
function categoryName(software: Software): string {
|
||||
return categoryOf(software)?.name ?? software.category;
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let activeCategory = $state('all');
|
||||
let activeStatus = $state<StatusFilter>('all');
|
||||
let selectedSoftware = $state<Software | null>(null);
|
||||
|
||||
let searchInput = $state<HTMLInputElement | null>(null);
|
||||
let gridEl = $state<HTMLElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let triggerElement: HTMLElement | null = null;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const result = softwares.filter((software) => {
|
||||
if (activeCategory !== 'all' && software.category !== activeCategory) return false;
|
||||
if (activeStatus !== 'all' && software.etat !== activeStatus) return false;
|
||||
if (query) {
|
||||
const haystack =
|
||||
`${software.name} ${software.description} ${software.category} ${categoryName(software)} ${software.language}`.toLowerCase();
|
||||
if (!haystack.includes(query)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
result.sort((a, b) => {
|
||||
const statusDiff = ETATS.indexOf(a.etat) - ETATS.indexOf(b.etat);
|
||||
return statusDiff !== 0 ? statusDiff : a.name.localeCompare(b.name);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
function resetFilters(): void {
|
||||
search = '';
|
||||
activeCategory = 'all';
|
||||
activeStatus = 'all';
|
||||
}
|
||||
|
||||
function openDetails(software: Software, trigger: HTMLElement | null): void {
|
||||
triggerElement = trigger;
|
||||
selectedSoftware = software;
|
||||
}
|
||||
|
||||
function closeDetails(): void {
|
||||
selectedSoftware = null;
|
||||
triggerElement?.focus();
|
||||
triggerElement = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Reprennabilité : restaure les filtres de la session
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof saved.search === 'string') search = saved.search;
|
||||
if (
|
||||
saved.activeCategory === 'all' ||
|
||||
(typeof saved.activeCategory === 'string' && categoryById.has(saved.activeCategory))
|
||||
) {
|
||||
activeCategory = saved.activeCategory as string;
|
||||
}
|
||||
if (
|
||||
saved.activeStatus === 'all' ||
|
||||
(typeof saved.activeStatus === 'string' &&
|
||||
(ETATS as readonly string[]).includes(saved.activeStatus))
|
||||
) {
|
||||
activeStatus = saved.activeStatus as StatusFilter;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stockage indisponible ou données corrompues : on ignore */
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (selectedSoftware) closeDetails();
|
||||
return;
|
||||
}
|
||||
if (selectedSoftware) return;
|
||||
if (isTypingTarget(event.target) || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
|
||||
if (event.key === '/') {
|
||||
event.preventDefault();
|
||||
searchInput?.focus();
|
||||
} else if (event.key === 'j' || event.key === 'k') {
|
||||
const cards = gridEl ? [...gridEl.querySelectorAll<HTMLElement>('.card')] : [];
|
||||
if (cards.length === 0) return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'j' ? 1 : -1;
|
||||
const current = cards.indexOf(document.activeElement as HTMLElement);
|
||||
const next =
|
||||
current === -1
|
||||
? delta === 1
|
||||
? 0
|
||||
: cards.length - 1
|
||||
: (current + delta + cards.length) % cards.length;
|
||||
cards[next].focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
// Persiste les filtres pour la session
|
||||
$effect(() => {
|
||||
const snapshot = { search, activeCategory, activeStatus };
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
/* stockage indisponible : on ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Focus sur le bouton fermer à l'ouverture de la modale
|
||||
$effect(() => {
|
||||
if (selectedSoftware) closeButton?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="catalog" aria-labelledby="catalog-title">
|
||||
<div class="inner center">
|
||||
<header class="section-header stack">
|
||||
<p class="kicker">Répertoire</p>
|
||||
<h2 id="catalog-title">Les logiciels du <span class="gradient-text">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
{softwares.length} logiciels référencés, classés par catégorie et filtrables par statut
|
||||
d'activité.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-field">
|
||||
<label class="visually-hidden" for="catalog-search">{t('common.search')}</label>
|
||||
<svg
|
||||
class="search-icon"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
|
||||
<path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
id="catalog-search"
|
||||
type="search"
|
||||
bind:this={searchInput}
|
||||
bind:value={search}
|
||||
placeholder="Rechercher un logiciel, un langage…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if search}
|
||||
<button
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
aria-label={t('common.clear')}
|
||||
onclick={() => {
|
||||
search = '';
|
||||
searchInput?.focus();
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="selects cluster">
|
||||
<label class="select-label">
|
||||
<span class="visually-hidden">Filtrer par catégorie</span>
|
||||
<select bind:value={activeCategory}>
|
||||
<option value="all">Toutes les catégories</option>
|
||||
{#each [...categoryById.values()] as category (category.id)}
|
||||
<option value={category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="select-label">
|
||||
<span class="visually-hidden">Filtrer par statut</span>
|
||||
<select bind:value={activeStatus}>
|
||||
<option value="all">Tous les statuts</option>
|
||||
{#each ETATS as etat (etat)}
|
||||
<option value={etat}>{etat}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="results-count" role="status">
|
||||
{t('catalog.results', { count: filtered.length })}<!--
|
||||
-->{#if activeCategory !== 'all'}
|
||||
dans <strong>{categoryById.get(activeCategory)?.name ?? activeCategory}</strong>{/if}<!--
|
||||
-->{#if activeStatus !== 'all'}
|
||||
· statut : <strong>{activeStatus}</strong>{/if}<!--
|
||||
-->{#if search.trim()}
|
||||
pour « <strong>{search.trim()}</strong> »{/if}
|
||||
</p>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty stack">
|
||||
<p>{t('common.emptyResults')}</p>
|
||||
<button type="button" class="reset-btn" onclick={resetFilters}>
|
||||
{t('common.resetFilters')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid catalog-grid" bind:this={gridEl}>
|
||||
{#each filtered as software (software.id)}
|
||||
{@const category = categoryOf(software)}
|
||||
<button
|
||||
type="button"
|
||||
class="card"
|
||||
style:--cat-color={category?.color ?? 'var(--muted)'}
|
||||
style:--tile-ink={contrastingInk(category?.color)}
|
||||
style:--status-color={statusColor[software.etat]}
|
||||
style:--compat-color={compatColor[software.compatibiliteAP]}
|
||||
aria-haspopup="dialog"
|
||||
onclick={(event) => openDetails(software, event.currentTarget)}
|
||||
>
|
||||
<span class="card-inner">
|
||||
<span class="tile" aria-hidden="true">{software.name.charAt(0)}</span>
|
||||
<span class="card-body">
|
||||
<span class="card-head">
|
||||
<span class="card-name">{software.name}</span>
|
||||
<span class="card-cat">{categoryName(software)}</span>
|
||||
</span>
|
||||
<span class="status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
{software.etat}
|
||||
</span>
|
||||
<span class="card-desc">{software.description}</span>
|
||||
<span class="chips">
|
||||
<span class="chip">{software.language}</span>
|
||||
<span class="chip compat">AP : {software.compatibiliteAP}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="end-marker">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
<path d="M12 8v13" />
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3" />
|
||||
</svg>
|
||||
{t('catalog.upToDate')} · {t('catalog.upToDateCreole')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if selectedSoftware}
|
||||
{@const software = selectedSoftware}
|
||||
{@const category = categoryOf(software)}
|
||||
<div class="backdrop" onclick={closeDetails} role="presentation"></div>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="software-dialog-title"
|
||||
style:--cat-color={category?.color ?? 'var(--muted)'}
|
||||
style:--tile-ink={contrastingInk(category?.color)}
|
||||
style:--status-color={statusColor[software.etat]}
|
||||
style:--compat-color={compatColor[software.compatibiliteAP]}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
bind:this={closeButton}
|
||||
aria-label={t('common.close')}
|
||||
onclick={closeDetails}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="dialog-head">
|
||||
<span class="tile tile-lg" aria-hidden="true">{software.name.charAt(0)}</span>
|
||||
<span class="dialog-title-block">
|
||||
<h3 id="software-dialog-title">{software.name}</h3>
|
||||
<span class="badges">
|
||||
<span class="chip cat-chip">{categoryName(software)}</span>
|
||||
<span class="status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
{software.etat}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="dialog-desc">{software.description}</p>
|
||||
|
||||
<dl class="details">
|
||||
<div class="detail-row">
|
||||
<dt>Protocoles</dt>
|
||||
<dd>{software.protocols.join(', ')}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>Licence</dt>
|
||||
<dd>{software.licence}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>Langage</dt>
|
||||
<dd>{software.language}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>ActivityPub</dt>
|
||||
<dd class="compat-value">{software.compatibiliteAP}</dd>
|
||||
</div>
|
||||
{#if software.version}
|
||||
<div class="detail-row">
|
||||
<dt>Version</dt>
|
||||
<dd>{software.version}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if software.createdYear}
|
||||
<div class="detail-row">
|
||||
<dt>Créé en</dt>
|
||||
<dd>{software.createdYear}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="dialog-actions">
|
||||
{#if software.site}
|
||||
<a href={software.site} target="_blank" rel="external noopener" class="btn-primary">
|
||||
{t('common.officialSite')}
|
||||
</a>
|
||||
{/if}
|
||||
{#if software.github}
|
||||
<a href={software.github} target="_blank" rel="external noopener" class="btn-outline">
|
||||
{t('common.sourceCode')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--surface);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--ink-soft);
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
/* --- Barre d'outils --- */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
flex: 1 1 18rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--space-3);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline-start: calc(var(--space-3) * 2 + 1.125rem);
|
||||
padding-inline-end: var(--tap-target);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--ink);
|
||||
transition: border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.search-field input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-field input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--space-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: calc(var(--tap-target) - var(--space-2));
|
||||
block-size: calc(var(--tap-target) - var(--space-2));
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.selects {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.select-label select {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-label select:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Compteur --- */
|
||||
|
||||
.results-count {
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.results-count strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* --- État vide --- */
|
||||
|
||||
.empty {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding-block: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--accent-text);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
/* --- Grille et cartes --- */
|
||||
|
||||
.catalog-grid {
|
||||
--grid-min: 19rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
container-type: inline-size;
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color var(--duration-1) var(--ease-out),
|
||||
box-shadow var(--duration-1) var(--ease-out),
|
||||
transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: color-mix(in oklch, var(--cat-color) 55%, var(--glass-border));
|
||||
box-shadow: var(--shadow-2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.75rem;
|
||||
block-size: 2.75rem;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--cat-color);
|
||||
color: var(--tile-ink);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.card-cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--status-color);
|
||||
box-shadow: 0 0 0.4rem color-mix(in oklch, var(--status-color) 60%, transparent);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.chip.compat {
|
||||
background: color-mix(in oklch, var(--compat-color) 14%, transparent);
|
||||
border-color: color-mix(in oklch, var(--compat-color) 45%, transparent);
|
||||
color: var(--compat-color);
|
||||
}
|
||||
|
||||
/* Container query : tuile au-dessus quand la carte est étroite */
|
||||
@container (max-width: 15rem) {
|
||||
.card-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Marqueur de fin (pas de scroll infini) --- */
|
||||
|
||||
.end-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-5);
|
||||
padding-block-start: var(--space-4);
|
||||
border-block-start: 1px solid var(--border);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* --- Modale détail --- */
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: oklch(10% 0.02 260 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
z-index: 90;
|
||||
inline-size: min(32rem, calc(100vw - 2 * var(--space-3)));
|
||||
max-block-size: 85dvh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
inset-block-start: var(--space-2);
|
||||
inset-inline-end: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-block-end: var(--space-3);
|
||||
padding-inline-end: var(--tap-target);
|
||||
}
|
||||
|
||||
.tile-lg {
|
||||
inline-size: 3.5rem;
|
||||
block-size: 3.5rem;
|
||||
font-size: var(--text-3);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.dialog-title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
background: color-mix(in oklch, var(--cat-color) 16%, transparent);
|
||||
border-color: color-mix(in oklch, var(--cat-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.dialog-desc {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.details {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-2);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-row dt {
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-row dd {
|
||||
color: var(--ink);
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compat-value {
|
||||
color: var(--compat-color);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.dialog-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1 1 auto;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { networkData, stats } from '$lib/data';
|
||||
import { timelineEvents, type TimelineEvent } from '$lib/data/timeline';
|
||||
|
||||
type CategoryKey = TimelineEvent['category'];
|
||||
type FilterKey = CategoryKey | 'all';
|
||||
|
||||
/* Couleurs de catégories : tokens sémantiques (adaptés au thème clair/sombre). */
|
||||
const categoryConfig = {
|
||||
foundation: { label: 'Fondation', color: 'var(--muted)' },
|
||||
protocol: { label: 'Protocole', color: 'var(--accent)' },
|
||||
software: { label: 'Logiciel', color: 'var(--accent-2)' },
|
||||
milestone: { label: 'Jalon', color: 'var(--status-maintenance)' },
|
||||
ecosystem: { label: 'Écosystème', color: 'var(--accent-4)' }
|
||||
} as const satisfies Record<CategoryKey, { label: string; color: string }>;
|
||||
|
||||
const categoryFilters: readonly { key: FilterKey; label: string; color: string }[] = [
|
||||
{ key: 'all', label: 'Tout', color: 'var(--accent)' },
|
||||
{ key: 'foundation', label: 'Fondations', color: categoryConfig.foundation.color },
|
||||
{ key: 'protocol', label: 'Protocoles', color: categoryConfig.protocol.color },
|
||||
{ key: 'software', label: 'Logiciels', color: categoryConfig.software.color },
|
||||
{ key: 'milestone', label: 'Jalons', color: categoryConfig.milestone.color },
|
||||
{ key: 'ecosystem', label: 'Écosystème', color: categoryConfig.ecosystem.color }
|
||||
];
|
||||
|
||||
let activeFilter = $state<FilterKey>('all');
|
||||
|
||||
const filtered = $derived(
|
||||
activeFilter === 'all'
|
||||
? timelineEvents
|
||||
: timelineEvents.filter((event) => event.category === activeFilter)
|
||||
);
|
||||
|
||||
const yearGroups = $derived.by(() => {
|
||||
const byYear = new Map<number, TimelineEvent[]>();
|
||||
for (const event of filtered) {
|
||||
const group = byYear.get(event.year);
|
||||
if (group) group.push(event);
|
||||
else byYear.set(event.year, [event]);
|
||||
}
|
||||
return [...byYear.entries()].sort((a, b) => a[0] - b[0]);
|
||||
});
|
||||
|
||||
const finalStats = [
|
||||
{ value: stats.yearsOfHistory, label: "Années d'histoire" },
|
||||
{ value: stats.totalSoftwares, label: 'Logiciels référencés' },
|
||||
{ value: stats.totalProtocols, label: 'Protocoles' },
|
||||
{ value: networkData.links.length, label: 'Liens de fédération' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section id="timeline" aria-labelledby="timeline-title">
|
||||
<div class="inner center">
|
||||
<header class="section-header stack">
|
||||
<p class="kicker">🕐 Chronologie</p>
|
||||
<h2 id="timeline-title">L'évolution du <span class="gradient-text">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
De StatusNet en 2008 à l'Atlas de 2026 : {stats.yearsOfHistory} ans d'histoire du réseau
|
||||
social décentralisé.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters cluster" role="group" aria-label="Filtrer la chronologie par catégorie">
|
||||
{#each categoryFilters as filter (filter.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
style:--cat-color={filter.color}
|
||||
aria-pressed={activeFilter === filter.key}
|
||||
onclick={() => (activeFilter = filter.key)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
{#each yearGroups as [year, events] (year)}
|
||||
<section class="year-group" aria-labelledby="timeline-year-{year}">
|
||||
<h3 class="year-badge" id="timeline-year-{year}">{year}</h3>
|
||||
<ol class="events" role="list">
|
||||
{#each events as event, i (`${year}-${event.title}`)}
|
||||
{@const cat = categoryConfig[event.category]}
|
||||
<li class="event" class:left={i % 2 === 0} class:right={i % 2 === 1}>
|
||||
<article class="event-card" style:--event-color={event.color}>
|
||||
<p class="event-meta">
|
||||
<span class="event-dot" aria-hidden="true"></span>
|
||||
<span class="event-cat" style:--cat-label-color={cat.color}>{cat.label}</span>
|
||||
</p>
|
||||
<h4 class="event-title">{event.title}</h4>
|
||||
<p class="event-desc">{event.description}</p>
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid stats" role="list">
|
||||
{#each finalStats as stat (stat.label)}
|
||||
<div class="stat-card" role="listitem">
|
||||
<p class="stat-value gradient-text">{stat.value}</p>
|
||||
<p class="stat-label">{stat.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--abyss);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.inner {
|
||||
--center-max: 64rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--status-maintenance);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--ink-soft);
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
/* --- Filtres --- */
|
||||
|
||||
.filters {
|
||||
justify-content: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 500;
|
||||
color: var(--ink-soft);
|
||||
transition:
|
||||
background-color var(--duration-1) var(--ease-out),
|
||||
border-color var(--duration-1) var(--ease-out),
|
||||
color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
border-color: var(--cat-color);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pill[aria-pressed='true'] {
|
||||
background: color-mix(in oklch, var(--cat-color) 22%, transparent);
|
||||
border-color: var(--cat-color);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Chronologie --- */
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Ligne verticale en dégradé : à gauche sur mobile, centrée sur desktop */
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--space-3);
|
||||
translate: -50% 0;
|
||||
inline-size: 2px;
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2), var(--accent-4));
|
||||
}
|
||||
|
||||
.year-group {
|
||||
position: relative;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
inline-size: fit-content;
|
||||
margin-inline: auto;
|
||||
margin-block-end: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.event {
|
||||
position: relative;
|
||||
padding-inline-start: var(--space-5);
|
||||
}
|
||||
|
||||
/* Pastille sur la ligne */
|
||||
.event::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: var(--space-3);
|
||||
inset-block-start: var(--space-4);
|
||||
translate: -50% 0;
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--event-color, var(--accent));
|
||||
border: 2px solid var(--abyss);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--event-color, var(--accent)) 45%, transparent);
|
||||
}
|
||||
|
||||
.event-card {
|
||||
inline-size: fit-content;
|
||||
max-inline-size: 100%;
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
flex-shrink: 0;
|
||||
inline-size: 0.5rem;
|
||||
block-size: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--event-color, var(--accent));
|
||||
box-shadow: 0 0 0.5rem color-mix(in oklch, var(--event-color, var(--accent)) 70%, transparent);
|
||||
}
|
||||
|
||||
.event-cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--cat-label-color);
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: var(--text-1);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* Alternance gauche/droite sur desktop */
|
||||
@media (min-width: 48rem) {
|
||||
.timeline::before {
|
||||
inset-inline-start: 50%;
|
||||
}
|
||||
|
||||
.event {
|
||||
inline-size: calc(50% - var(--space-5));
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.event.left {
|
||||
margin-inline-end: auto;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.event.left .event-card {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.event.left .event-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.event.left::before {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: calc(-1 * var(--space-5));
|
||||
translate: 50% 0;
|
||||
}
|
||||
|
||||
.event.right {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.event.right::before {
|
||||
inset-inline-start: calc(-1 * var(--space-5));
|
||||
}
|
||||
}
|
||||
|
||||
/* Container query : carte compacte quand la chronologie est étroite */
|
||||
@container (max-width: 30rem) {
|
||||
.event-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Statistiques finales --- */
|
||||
|
||||
.stats {
|
||||
--grid-min: 11rem;
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: var(--space-3);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
export interface CompareRow {
|
||||
aspect: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Légende visible du tableau (sert aussi de description accessible). */
|
||||
caption: string;
|
||||
/** Libellé de la première colonne (en-têtes de ligne). */
|
||||
firstColumn?: string;
|
||||
/** En-têtes des colonnes de données. */
|
||||
columns: string[];
|
||||
rows: CompareRow[];
|
||||
/** Index dans `columns` de la colonne mise en valeur (-1 = aucune). */
|
||||
highlight?: number;
|
||||
/** Étiquette de la région de défilement horizontal. */
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
let { caption, firstColumn = 'Aspect', columns, rows, highlight = -1, ariaLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex — région défilante : tabindex="0" la rend
|
||||
accessible au clavier (défilement horizontal du tableau), c'est voulu. -->
|
||||
<div class="table-scroll" role="region" aria-label={ariaLabel} tabindex="0">
|
||||
<table>
|
||||
<caption>{caption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{firstColumn}</th>
|
||||
{#each columns as column, i (column)}
|
||||
<th scope="col" class:highlight={i === highlight}>{column}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.aspect)}
|
||||
<tr>
|
||||
<th scope="row">{row.aspect}</th>
|
||||
{#each row.values as value, i (i)}
|
||||
<td class:highlight={i === highlight}>{value}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
table {
|
||||
inline-size: 100%;
|
||||
min-inline-size: 34rem;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
caption {
|
||||
text-align: start;
|
||||
padding: var(--space-3) var(--space-3) 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
|
||||
tbody tr:last-child th,
|
||||
tbody tr:last-child td {
|
||||
border-block-end: 0;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
border-block-end-color: var(--border-strong);
|
||||
}
|
||||
|
||||
tbody th {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* Colonne mise en valeur : couleur accent ET fond teinté + texte en gras
|
||||
(l'information n'est jamais portée par la couleur seule côté contenu). */
|
||||
th.highlight,
|
||||
td.highlight {
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 9%, transparent);
|
||||
}
|
||||
|
||||
thead th.highlight {
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
}
|
||||
|
||||
td.highlight {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
tbody tr:hover th,
|
||||
tbody tr:hover td {
|
||||
background: color-mix(in oklch, var(--ink) 5%, transparent);
|
||||
}
|
||||
|
||||
tbody tr:hover td.highlight {
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
export interface FeatureItem {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: FeatureItem[];
|
||||
/** Taille minimale des colonnes de la grille (primitive .grid). */
|
||||
min?: string;
|
||||
}
|
||||
|
||||
let { items, min = '15rem' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="feature-cq">
|
||||
<ul class="grid" role="list" style:--grid-min={min}>
|
||||
{#each items as item (item.title)}
|
||||
<li class="feature-card">
|
||||
<span class="feature-icon"><Icon name={item.icon} /></span>
|
||||
<div class="feature-text">
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container query : la carte passe en disposition horizontale
|
||||
quand son conteneur (pas le viewport) est assez large. */
|
||||
.feature-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.75rem;
|
||||
block-size: 2.75rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 30rem) {
|
||||
.feature-card {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" module>
|
||||
/* Icônes SVG inline (24×24, trait currentColor) — tracés statiques locaux,
|
||||
aucune dépendance. Contenu interne de confiance, injecté via {@html}. */
|
||||
const ICONS = {
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11.2 12.2 20 3.5"/><path d="m16 7 3 3"/>',
|
||||
server:
|
||||
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/>',
|
||||
'server-off':
|
||||
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/><path d="m3 3 18 18"/>',
|
||||
lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
zap: '<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>',
|
||||
bitcoin:
|
||||
'<path d="M9 4h4a3 3 0 0 1 0 6H9V4z"/><path d="M9 10h5a3 3 0 0 1 0 6H9v-6z"/><path d="M11 2v2"/><path d="M14 2v2"/><path d="M11 20v-2"/><path d="M14 20v-2"/>',
|
||||
radio:
|
||||
'<circle cx="12" cy="12" r="1.5"/><path d="M8.5 8.5a5 5 0 0 0 0 7"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M5.6 5.6a9 9 0 0 0 0 12.8"/><path d="M18.4 5.6a9 9 0 0 1 0 12.8"/>',
|
||||
globe:
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a13.5 13.5 0 0 1 0 18"/><path d="M12 3a13.5 13.5 0 0 0 0 18"/>',
|
||||
message:
|
||||
'<path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 20l1.2-5A8.5 8.5 0 1 1 21 11.5z"/>',
|
||||
external:
|
||||
'<path d="M14 4h6v6"/><path d="M20 4 10 14"/><path d="M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6"/>',
|
||||
github:
|
||||
'<path d="M15 21v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.6 1.3a12.3 12.3 0 0 0-6.3 0C6.2 2.8 5.1 3.1 5.1 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/><path d="M9 18c-4.5 1.5-4.5-2.5-6-3"/>',
|
||||
shield: '<path d="M12 2 4 5.5v5.6c0 4.9 3.4 9.3 8 10.9 4.6-1.6 8-6 8-10.9V5.5L12 2z"/>',
|
||||
'user-x':
|
||||
'<circle cx="9" cy="8" r="4"/><path d="M2.5 21v-1a6.5 6.5 0 0 1 11-4.7"/><path d="m17 10 5 5"/><path d="m22 10-5 5"/>',
|
||||
'eye-off':
|
||||
'<path d="m3 3 18 18"/><path d="M10.5 5.2A9.8 9.8 0 0 1 12 5c7 0 10 7 10 7a17.4 17.4 0 0 1-2.9 3.6"/><path d="M6.6 6.6A16.9 16.9 0 0 0 2 12s3 7 10 7a10 10 0 0 0 4.3-1"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/>',
|
||||
fingerprint:
|
||||
'<path d="M6.8 6.8A7 7 0 0 1 19 13c0 2.8-.4 5.4-1.1 7.5"/><path d="M5.2 10A7 7 0 0 0 5 13c0 3-.9 5.2-1.9 6.7"/><path d="M12 11a2 2 0 0 0-2 2c0 2.6-.6 5-1.7 7"/><path d="M14 13a2 2 0 0 0-2-2"/><path d="M14 13c0 2.9-.3 5.5-1 8"/>',
|
||||
phone:
|
||||
'<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 2 .7 2.9a2 2 0 0 1-.4 2.1L8.1 10a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.9.6 2.9.7a2 2 0 0 1 1.7 2z"/>',
|
||||
users:
|
||||
'<circle cx="9" cy="8" r="4"/><path d="M2 21v-1a7 7 0 0 1 14 0v1"/><path d="M16.5 4.2a4 4 0 0 1 0 7.6"/><path d="M18 14.3A7 7 0 0 1 22 20v1"/>',
|
||||
chevron: '<path d="m6 9 6 6 6-6"/>'
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, size = 20 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html ICONS[name]}
|
||||
</svg>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
export interface StepItem {
|
||||
step: string;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: StepItem[];
|
||||
}
|
||||
|
||||
let { items }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="steps-cq">
|
||||
<ol class="grid" role="list" style:--grid-min="17rem">
|
||||
{#each items as item (item.step)}
|
||||
<li class="step-card">
|
||||
<span class="step-number" aria-hidden="true">{item.step}</span>
|
||||
<span class="step-icon"><Icon name={item.icon} size={24} /></span>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.description}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container query : rythme plus généreux quand le conteneur s'élargit. */
|
||||
.steps-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-4);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: color-mix(in oklch, var(--page-accent, var(--accent)) 25%, transparent);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 45rem) {
|
||||
.step-card {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
export interface Bridge {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fromProtocol: string;
|
||||
toProtocol: string;
|
||||
fromColor: string;
|
||||
toColor: string;
|
||||
status: 'Actif' | 'En développement' | 'Expérimental' | 'Planifié';
|
||||
url?: string;
|
||||
github?: string;
|
||||
details: string;
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export const bridges: Bridge[] = [
|
||||
{
|
||||
id: 'mostr',
|
||||
name: 'Mostr',
|
||||
description: 'Pont unidirectionnel ActivityPub → Nostr. Permet de suivre des comptes ActivityPub depuis Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'Actif',
|
||||
url: 'https://mostr.pub/',
|
||||
github: 'https://github.com/gfanton/mostr',
|
||||
details: 'Mostr relay publie les activités ActivityPub sur Nostr. Créé par Guilhem Fanton (gfanton). Les utilisateurs Nostr peuvent suivre des comptes Mastodon/Pleroma via des clés npub dérivées.',
|
||||
limitations: [
|
||||
'Unidirectionnel (AP → Nostr uniquement)',
|
||||
'Ne supporte pas encore les médias riches',
|
||||
'Dépend de la disponibilité des instances AP',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nostr-ap-bridge',
|
||||
name: 'Nostr-AP Bridge',
|
||||
description: 'Pont bidirectionnel expérimental entre Nostr et ActivityPub. Synchronise notes et réponses.',
|
||||
fromProtocol: 'Nostr',
|
||||
toProtocol: 'ActivityPub',
|
||||
fromColor: '#F7931A',
|
||||
toColor: '#F72585',
|
||||
status: 'Expérimental',
|
||||
github: 'https://github.com/nostr-protocol/nostr',
|
||||
details: 'Implémentation expérimentale permettant la publication croisée entre les deux protocoles. Les notes Nostr apparaissent comme des posts ActivityPub et vice-versa.',
|
||||
limitations: [
|
||||
'En phase expérimentale précoce',
|
||||
'Problèmes de formatage des contenus riches',
|
||||
'Gestion des threads complexe',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'activitypub-nostr-gateway',
|
||||
name: 'AP-Nostr Gateway',
|
||||
description: 'Passerelle permettant aux instances ActivityPub de communiquer avec le réseau Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'En développement',
|
||||
github: 'https://github.com/fediverse',
|
||||
details: 'Projet communautaire visant à créer une passerelle standard entre ActivityPub et Nostr. Supporte la conversion de formats d\'activités.',
|
||||
limitations: [
|
||||
'Non standardisé',
|
||||
'Performance limitée',
|
||||
'Nécessite un relay dédié',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fedify-nostr',
|
||||
name: 'Fedify + Nostr',
|
||||
description: 'Le framework Fedify explore le support multi-protocoles incluant Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'Planifié',
|
||||
url: 'https://fedify.dev/',
|
||||
github: 'https://github.com/dahlia/fedify',
|
||||
details: 'Fedify, le framework TypeScript pour ActivityPub, envisage d\'ajouter le support Nostr pour permettre aux développeurs de construire des applications multi-protocoles.',
|
||||
limitations: [
|
||||
'En phase de planification',
|
||||
'Pas de release date annoncée',
|
||||
'Complexité technique élevée',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'soapbox-nostr',
|
||||
name: 'Soapbox Nostr Bridge',
|
||||
description: 'Pont intégré à Soapbox (frontend Pleroma) pour la cross-publication Nostr.',
|
||||
fromProtocol: 'ActivityPub',
|
||||
toProtocol: 'Nostr',
|
||||
fromColor: '#F72585',
|
||||
toColor: '#F7931A',
|
||||
status: 'En développement',
|
||||
url: 'https://soapbox.pub/',
|
||||
details: 'Soapbox, le frontend alternatif pour Pleroma, développe un module de bridge Nostr permettant aux utilisateurs de cross-publier leurs contenus.',
|
||||
limitations: [
|
||||
'Spécifique à Soapbox/Pleroma',
|
||||
'Configuration requise par l\'administrateur',
|
||||
'Support partiel des formats',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const bridgeProtocolOrder = [
|
||||
{ name: 'ActivityPub', color: '#F72585' },
|
||||
{ name: 'Nostr', color: '#F7931A' },
|
||||
{ name: 'XMPP', color: '#06D6A0' },
|
||||
{ name: 'Matrix', color: '#4361EE' },
|
||||
{ name: 'AT Protocol', color: '#4CC9F0' },
|
||||
{ name: 'SimpleX', color: '#2EC4B6' },
|
||||
];
|
||||
|
||||
export const bridgeStatusInfo: Record<string, { label: string; color: string }> = {
|
||||
Actif: { label: '✅ Actif', color: '#2EC4B6' },
|
||||
'En développement': { label: '🔧 En développement', color: '#F9C74F' },
|
||||
Expérimental: { label: '🧪 Expérimental', color: '#F7931A' },
|
||||
Planifié: { label: '📋 Planifié', color: '#4CC9F0' },
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"id": "social",
|
||||
"name": "Réseaux sociaux",
|
||||
"icon": "Users",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Plateformes sociales fédérées"
|
||||
},
|
||||
{
|
||||
"id": "microblog",
|
||||
"name": "Microblogging",
|
||||
"icon": "MessageSquare",
|
||||
"color": "#F9C74F",
|
||||
"description": "Micro-blogs et status updates"
|
||||
},
|
||||
{
|
||||
"id": "photo",
|
||||
"name": "Photographie",
|
||||
"icon": "Camera",
|
||||
"color": "#F8961E",
|
||||
"description": "Partage de photos et images"
|
||||
},
|
||||
{
|
||||
"id": "video",
|
||||
"name": "Vidéo",
|
||||
"icon": "Video",
|
||||
"color": "#F72585",
|
||||
"description": "Streaming et partage vidéo"
|
||||
},
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "Audio & Podcast",
|
||||
"icon": "Headphones",
|
||||
"color": "#B5179E",
|
||||
"description": "Musique, podcasts et audio"
|
||||
},
|
||||
{
|
||||
"id": "blog",
|
||||
"name": "Blogs",
|
||||
"icon": "FileText",
|
||||
"color": "#7209B7",
|
||||
"description": "Plateformes de blogging"
|
||||
},
|
||||
{
|
||||
"id": "forum",
|
||||
"name": "Forums",
|
||||
"icon": "MessageCircle",
|
||||
"color": "#3A0CA3",
|
||||
"description": "Forums et agrégateurs"
|
||||
},
|
||||
{
|
||||
"id": "events",
|
||||
"name": "Événements",
|
||||
"icon": "Calendar",
|
||||
"color": "#4361EE",
|
||||
"description": "Gestion d'événements"
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Développement",
|
||||
"icon": "Code",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Forge et développement logiciel"
|
||||
},
|
||||
{
|
||||
"id": "collab",
|
||||
"name": "Collaboration",
|
||||
"icon": "FolderOpen",
|
||||
"color": "#2EC4B6",
|
||||
"description": "Outils collaboratifs et cloud"
|
||||
},
|
||||
{
|
||||
"id": "books",
|
||||
"name": "Livres",
|
||||
"icon": "BookOpen",
|
||||
"color": "#E71D36",
|
||||
"description": "Lecture et partage de livres"
|
||||
},
|
||||
{
|
||||
"id": "wiki",
|
||||
"name": "Wiki",
|
||||
"icon": "Globe",
|
||||
"color": "#662E9B",
|
||||
"description": "Encyclopédies collaboratives"
|
||||
},
|
||||
{
|
||||
"id": "messaging",
|
||||
"name": "Messagerie",
|
||||
"icon": "Send",
|
||||
"color": "#06D6A0",
|
||||
"description": "Messagerie instantanée privée et sécurisée"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"icon": "Zap",
|
||||
"color": "#F7931A",
|
||||
"description": "Écosystème Nostr — protocole voisin"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
categories,
|
||||
categoryById,
|
||||
networkData,
|
||||
protocols,
|
||||
softwares,
|
||||
stats
|
||||
} from './index';
|
||||
import { timelineEvents } from './timeline';
|
||||
import { bridges } from './bridges';
|
||||
|
||||
describe('données de l’Atlas', () => {
|
||||
it('référence 105 logiciels, 12 protocoles, 14 catégories', () => {
|
||||
expect(softwares).toHaveLength(105);
|
||||
expect(protocols).toHaveLength(12);
|
||||
expect(categories).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('chaque logiciel pointe vers une catégorie existante', () => {
|
||||
for (const software of softwares) {
|
||||
expect(categoryById.has(software.category)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('les compteurs dérivés sont cohérents avec les métadonnées', () => {
|
||||
expect(stats.totalSoftwares).toBe(105);
|
||||
expect(stats.activeSoftwares).toBe(93);
|
||||
});
|
||||
|
||||
it('le graphe réseau est déterministe et couvre les 105 logiciels', () => {
|
||||
expect(networkData.nodes).toHaveLength(105);
|
||||
expect(networkData.links.length).toBeGreaterThan(0);
|
||||
const nodeIds = new Set(networkData.nodes.map((n) => n.id));
|
||||
for (const link of networkData.links) {
|
||||
const source = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const target = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
expect(nodeIds.has(source)).toBe(true);
|
||||
expect(nodeIds.has(target)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('la chronologie couvre 2008 → 2026 dans l’ordre croissant', () => {
|
||||
expect(timelineEvents.length).toBeGreaterThanOrEqual(30);
|
||||
expect(timelineEvents[0].year).toBe(2008);
|
||||
for (let i = 1; i < timelineEvents.length; i++) {
|
||||
expect(timelineEvents[i].year).toBeGreaterThanOrEqual(timelineEvents[i - 1].year);
|
||||
}
|
||||
});
|
||||
|
||||
it('les ponts Nostr ↔ ActivityPub sont présents', () => {
|
||||
expect(bridges.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Point d'entrée des données de l'Atlas — modules JSON typés.
|
||||
Source de vérité : /public/data du projet v4 (réutilisés tels quels). */
|
||||
|
||||
import softwareJson from './software.json';
|
||||
import protocolsJson from './protocols.json';
|
||||
import categoriesJson from './categories.json';
|
||||
import networkJson from './network.json';
|
||||
import metadataJson from './metadata.json';
|
||||
import type { Category, NetworkData, Protocol, Software } from './types';
|
||||
|
||||
export const softwares = softwareJson as Software[];
|
||||
export const protocols = protocolsJson as Protocol[];
|
||||
export const categories = categoriesJson as Category[];
|
||||
export const networkData = networkJson as NetworkData;
|
||||
export const metadata = metadataJson;
|
||||
|
||||
export const categoryById: ReadonlyMap<string, Category> = new Map(categories.map((c) => [c.id, c]));
|
||||
|
||||
export const softwareById: ReadonlyMap<string, Software> = new Map(softwares.map((s) => [s.id, s]));
|
||||
|
||||
/** Ordre de tri des états d'activité (Actif d'abord). */
|
||||
export const ETAT_ORDER: readonly Software['etat'][] = [
|
||||
'Actif',
|
||||
'Maintenance',
|
||||
'Experimental',
|
||||
'Historique'
|
||||
];
|
||||
|
||||
export const ETATS: readonly Software['etat'][] = ETAT_ORDER;
|
||||
|
||||
export const COMPATIBILITES_AP: readonly Software['compatibiliteAP'][] = [
|
||||
'Pleine',
|
||||
'Partielle',
|
||||
'Extension',
|
||||
'Aucune',
|
||||
'En développement'
|
||||
];
|
||||
|
||||
export function softwaresByCategory(categoryId: string): Software[] {
|
||||
return softwares.filter((s) => s.category === categoryId);
|
||||
}
|
||||
|
||||
export const stats = {
|
||||
totalSoftwares: softwares.length,
|
||||
totalProtocols: protocols.length,
|
||||
totalCategories: categories.length,
|
||||
activeSoftwares: softwares.filter((s) => s.etat === 'Actif').length,
|
||||
networkLinks: networkData.links.length,
|
||||
yearsOfHistory: new Date().getFullYear() - 2008
|
||||
} as const;
|
||||
|
||||
export type { Category, NetworkData, NetworkLink, NetworkNode, Protocol, Software } from './types';
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Le Grand Atlas du Fédiverse",
|
||||
"version": "4.0",
|
||||
"year": 2026,
|
||||
"language": "fr",
|
||||
"licence": "CC BY-SA 4.0",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"statistics": {
|
||||
"totalSoftwares": 105,
|
||||
"totalProtocols": 12,
|
||||
"totalCategories": 14,
|
||||
"activeSoftwares": 93,
|
||||
"maintenanceSoftwares": 4,
|
||||
"experimentalSoftwares": 5,
|
||||
"historicalSoftwares": 3,
|
||||
"fullAPCompatibility": 45,
|
||||
"partialAPCompatibility": 4,
|
||||
"extensionAPCompatibility": 9
|
||||
},
|
||||
"attribution": {
|
||||
"inspiredBy": "Per Axbom (axbom.com/fediverse)",
|
||||
"originalVersion": "3.0 (January 2023)",
|
||||
"maintainers": [
|
||||
"Community"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
[
|
||||
{
|
||||
"id": "activitypub",
|
||||
"name": "ActivityPub",
|
||||
"description": "Protocole de réseautage social décentralisé recommandé par le W3C. Le standard principal du Fédiverse.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"site": "https://activitypub.rocks/",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "activitystreams",
|
||||
"name": "ActivityStreams 2.0",
|
||||
"description": "Format de données pour décrire les activités sociales. Base sur laquelle repose ActivityPub.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "webfinger",
|
||||
"name": "WebFinger",
|
||||
"description": "Protocole de découverte d'informations sur les ressources identifiées par des URI web.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 7033"
|
||||
},
|
||||
{
|
||||
"id": "httpsignatures",
|
||||
"name": "HTTP Signatures",
|
||||
"description": "Mécanisme de signature des requêtes HTTP pour l'authentification entre instances.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "IETF Draft"
|
||||
},
|
||||
{
|
||||
"id": "nodeinfo",
|
||||
"name": "NodeInfo",
|
||||
"description": "Schéma de métadonnées pour décrire les instances fédérées et leurs capacités.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "nodeinfo.diaspora.software"
|
||||
},
|
||||
{
|
||||
"id": "zot",
|
||||
"name": "Zot / Nomad",
|
||||
"description": "Protocole de communications décentralisées avec prise en charge avancée de la privacy et du nomadisme d'identité.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://zotlabs.org/"
|
||||
},
|
||||
{
|
||||
"id": "diaspora",
|
||||
"name": "Diaspora* Protocol",
|
||||
"description": "Protocole historique du réseau Diaspora*, précurseur du Fédiverse moderne.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "ostatus",
|
||||
"name": "OStatus",
|
||||
"description": "Suite de protocoles (Atom, Salmon, WebSub) utilisée par les premières plateformes fédérées.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "matrix",
|
||||
"name": "Matrix Protocol",
|
||||
"description": "Protocole ouvert pour la communication temps réel (chat, VoIP). Interopère avec ActivityPub.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://matrix.org/"
|
||||
},
|
||||
{
|
||||
"id": "xmpp",
|
||||
"name": "XMPP",
|
||||
"description": "Extensible Messaging and Presence Protocol. Standard historique de messagerie instantanée.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 6120"
|
||||
},
|
||||
{
|
||||
"id": "atproto",
|
||||
"name": "AT Protocol",
|
||||
"description": "Protocole d'identité décentralisé utilisé par Bluesky. Écosystème voisin mais distinct du Fédiverse.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://atproto.com/"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"description": "Protocole simple et ouvert pour créer des réseaux sociaux résistants à la censure.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://nostr.com/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,418 @@
|
||||
export interface TimelineEvent {
|
||||
year: number;
|
||||
month?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: 'foundation' | 'protocol' | 'software' | 'milestone' | 'ecosystem';
|
||||
softwareId?: string;
|
||||
protocolId?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const timelineEvents: TimelineEvent[] = [
|
||||
// 2008
|
||||
{
|
||||
year: 2008,
|
||||
title: 'StatusNet / Identi.ca',
|
||||
description: "Evan Prodromou lance Identi.ca basé sur StatusNet. Première plateforme de microblogging décentralisée utilisant le protocole OStatus.",
|
||||
category: 'foundation',
|
||||
color: '#6C757D',
|
||||
},
|
||||
// 2010
|
||||
{
|
||||
year: 2010,
|
||||
title: 'GNU social',
|
||||
description: "Fork de StatusNet. Devient le pionnier du réseau social fédéré avec le protocole OStatus.",
|
||||
category: 'software',
|
||||
softwareId: 'gnusocial',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2010,
|
||||
title: 'Diaspora*',
|
||||
description: "Lancement de Diaspora* après une campagne Kickstarter. Réseau social décentralisé avec son propre protocole. Précurseur du Fédiverse moderne.",
|
||||
category: 'software',
|
||||
softwareId: 'diaspora-soft',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2010,
|
||||
title: 'ownCloud',
|
||||
description: "Lancement de la plateforme de cloud auto-hébergeable. Posera plus tard les bases de Nextcloud.",
|
||||
category: 'software',
|
||||
softwareId: 'owncloud',
|
||||
color: '#2EC4B6',
|
||||
},
|
||||
// 2011
|
||||
{
|
||||
year: 2011,
|
||||
title: 'Friendica',
|
||||
description: "Plateforme sociale fédérée avec support multi-protocoles (OStatus, Diaspora*, plus tard ActivityPub).",
|
||||
category: 'software',
|
||||
softwareId: 'friendica',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2012
|
||||
{
|
||||
year: 2012,
|
||||
title: 'Hubzilla',
|
||||
description: "Plateforme de communications décentralisées avec contrôle d'accès granulaire et nomadisme d'identité via le protocole Zot.",
|
||||
category: 'software',
|
||||
softwareId: 'hubzilla',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2012,
|
||||
title: 'W3C Social Web Working Group',
|
||||
description: "Création du groupe de travail W3C sur le web social. Aboutira à la standardisation d'ActivityPub.",
|
||||
category: 'foundation',
|
||||
color: '#F72585',
|
||||
},
|
||||
// 2014
|
||||
{
|
||||
year: 2014,
|
||||
title: 'Misskey',
|
||||
description: "Lancement au Japon de Misskey, une plateforme sociale fédérée avec des fonctionnalités créatives uniques.",
|
||||
category: 'software',
|
||||
softwareId: 'misskey',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2015
|
||||
{
|
||||
year: 2015,
|
||||
title: 'PeerTube',
|
||||
description: "Framasoft lance PeerTube, une plateforme vidéo fédérée et décentralisée. Alternative libre à YouTube.",
|
||||
category: 'software',
|
||||
softwareId: 'peertube',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2015,
|
||||
title: 'Mastodon',
|
||||
description: "Eugen Rochko lance Mastodon. Le début de l'explosion du Fédiverse moderne. Utilise OStatus, migrera plus tard vers ActivityPub.",
|
||||
category: 'software',
|
||||
softwareId: 'mastodon',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2016
|
||||
{
|
||||
year: 2016,
|
||||
title: 'Pleroma',
|
||||
description: "Serveur ActivityPub léger et configurable. Alternative rapide et ressource-efficiente à Mastodon.",
|
||||
category: 'software',
|
||||
softwareId: 'pleroma',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2016,
|
||||
title: 'Matrix Protocol',
|
||||
description: "Standardisation du protocole Matrix pour la communication temps réel décentralisée.",
|
||||
category: 'protocol',
|
||||
color: '#4361EE',
|
||||
},
|
||||
{
|
||||
year: 2016,
|
||||
title: 'Nextcloud',
|
||||
description: "Fork de ownCloud devenu la référence du cloud auto-hébergeable. Ajoutera plus tard le support ActivityPub.",
|
||||
category: 'software',
|
||||
softwareId: 'nextcloud',
|
||||
color: '#2EC4B6',
|
||||
},
|
||||
// 2017
|
||||
{
|
||||
year: 2017,
|
||||
title: 'W3C ActivityPub — Candidate Recommendation',
|
||||
description: "ActivityPub atteint le statut de Candidate Recommendation au W3C. Le Fédiverse a désormais son standard.",
|
||||
category: 'protocol',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2017,
|
||||
title: 'PixelFed',
|
||||
description: "Lancement de PixelFed, plateforme de partage de photos fédérée. Alternative à Instagram.",
|
||||
category: 'software',
|
||||
softwareId: 'pixelfed',
|
||||
color: '#F8961E',
|
||||
},
|
||||
{
|
||||
year: 2017,
|
||||
title: 'WriteFreely',
|
||||
description: "Plateforme de blogging minimaliste et fédérée axée sur l'écriture pure.",
|
||||
category: 'software',
|
||||
softwareId: 'writefreely',
|
||||
color: '#7209B7',
|
||||
},
|
||||
// 2018
|
||||
{
|
||||
year: 2018,
|
||||
title: 'ActivityPub — W3C Recommendation',
|
||||
description: "ActivityPub devient une recommandation officielle du W3C. Le standard est désormais finalisé.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'BookWyrm',
|
||||
description: "Réseau social de lecture fédéré. Alternative libre à Goodreads.",
|
||||
category: 'software',
|
||||
softwareId: 'bookwyrm',
|
||||
color: '#E71D36',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'Plume',
|
||||
description: "Moteur de blog fédéré axé sur la collaboration et les publications longues.",
|
||||
category: 'software',
|
||||
softwareId: 'plume',
|
||||
color: '#7209B7',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'Funkwhale',
|
||||
description: "Plateforme musicale fédérée pour écouter, partager et découvrir de la musique.",
|
||||
category: 'software',
|
||||
softwareId: 'funkwhale',
|
||||
color: '#B5179E',
|
||||
},
|
||||
{
|
||||
year: 2018,
|
||||
title: 'microblog.pub',
|
||||
description: "Serveur ActivityPub minimaliste et auto-hébergeable pour le microblogging.",
|
||||
category: 'software',
|
||||
softwareId: 'microblogpub',
|
||||
color: '#F9C74F',
|
||||
},
|
||||
// 2019
|
||||
{
|
||||
year: 2019,
|
||||
title: 'Lemmy',
|
||||
description: "Agrégateur de liens et forum fédéré. Alternative à Reddit.",
|
||||
category: 'software',
|
||||
softwareId: 'lemmy',
|
||||
color: '#3A0CA3',
|
||||
},
|
||||
{
|
||||
year: 2019,
|
||||
title: 'Mobilizon',
|
||||
description: "Plateforme d'événements fédérée par Framasoft. Alternative à Facebook Events.",
|
||||
category: 'software',
|
||||
softwareId: 'mobilizon',
|
||||
color: '#4361EE',
|
||||
},
|
||||
{
|
||||
year: 2019,
|
||||
title: 'WordPress + ActivityPub',
|
||||
description: "Automattic publie le plugin ActivityPub pour WordPress. Le plus grand CMS du monde rejoint le Fédiverse.",
|
||||
category: 'software',
|
||||
softwareId: 'wordpress-ap',
|
||||
color: '#7209B7',
|
||||
},
|
||||
{
|
||||
year: 2019,
|
||||
title: 'Nostr Protocol',
|
||||
description: "Publication du protocole Nostr par fiatjaf. Une approche radicalement différente : pas de serveurs, pas d'instances, juste des relays et des clés.",
|
||||
category: 'protocol',
|
||||
color: '#F7931A',
|
||||
},
|
||||
// 2020
|
||||
{
|
||||
year: 2020,
|
||||
title: 'Owncast',
|
||||
description: "Serveur de streaming live auto-hébergeable. Alternative à Twitch.",
|
||||
category: 'software',
|
||||
softwareId: 'owncast',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2020,
|
||||
title: 'Bonfire',
|
||||
description: "Réseau social modulaire axé sur la gouvernance communautaire et la coordination sociale.",
|
||||
category: 'software',
|
||||
softwareId: 'bonfire',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2020,
|
||||
title: 'Castopod',
|
||||
description: "Plateforme de podcasting fédérée avec ActivityPub. Alternative à Spotify/Apple Podcasts.",
|
||||
category: 'software',
|
||||
softwareId: 'castopod',
|
||||
color: '#B5179E',
|
||||
},
|
||||
{
|
||||
year: 2020,
|
||||
title: 'SimpleX Chat',
|
||||
description: "Lancement de SimpleX Chat, une messagerie décentralisée de nouvelle génération sans identifiants utilisateur.",
|
||||
category: 'software',
|
||||
softwareId: 'simplexchat',
|
||||
color: '#06D6A0',
|
||||
},
|
||||
// 2021
|
||||
{
|
||||
year: 2021,
|
||||
title: 'Twitter → Mastodon',
|
||||
description: "Vague massive de migrations de Twitter vers Mastodon après l'acquisition par Elon Musk. Le Fédiverse passe de 2M à 10M+ d'utilisateurs.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2021,
|
||||
title: 'GoToSocial',
|
||||
description: "Serveur ActivityPub léger écrit en Go. Approche minimaliste et efficace pour auto-héberger son instance.",
|
||||
category: 'software',
|
||||
softwareId: 'gotosocial',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2022
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Akkoma',
|
||||
description: "Fork de Pleroma avec des fonctionnalités enrichies et une personnalisation poussée.",
|
||||
category: 'software',
|
||||
softwareId: 'akkoma',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Mbin',
|
||||
description: "Successeur de kbin, agrégateur de liens fédéré avec améliorations significatives.",
|
||||
category: 'software',
|
||||
softwareId: 'mbin',
|
||||
color: '#3A0CA3',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Gancio',
|
||||
description: "Agrégateur d'événements fédéré auto-hébergeable.",
|
||||
category: 'software',
|
||||
softwareId: 'gancio',
|
||||
color: '#4361EE',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Forgejo',
|
||||
description: "Forge logicielle libre forkée de Gitea. Le GitHub libre se fédère.",
|
||||
category: 'software',
|
||||
softwareId: 'forgejo',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Mitra',
|
||||
description: "Serveur ActivityPub en Rust avec support des monnaies complémentaires et des économies locales.",
|
||||
category: 'software',
|
||||
softwareId: 'mitra',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2022,
|
||||
title: 'Streams',
|
||||
description: "Fork de Hubzilla axé sur la privacy et le contrôle granulaire des permissions.",
|
||||
category: 'software',
|
||||
softwareId: 'streams',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2023
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Threads → ActivityPub',
|
||||
description: "Meta annonce que Threads intégrera ActivityPub. Le géant des réseaux sociaux rejoint le protocole fédéré.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Sharkey',
|
||||
description: "Fork de Misskey avec améliorations de performance et nouvelles fonctionnalités.",
|
||||
category: 'software',
|
||||
softwareId: 'sharkey',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Iceshrimp',
|
||||
description: "Fork de Misskey optimisé pour la performance et la stabilité.",
|
||||
category: 'software',
|
||||
softwareId: 'iceshrimp',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'NodeBB + ActivityPub',
|
||||
description: "NodeBB ajoute le support ActivityPub. Les forums classiques rejoignent le Fédiverse.",
|
||||
category: 'software',
|
||||
softwareId: 'nodebb-ap',
|
||||
color: '#3A0CA3',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Ghost + ActivityPub',
|
||||
description: "Ghost intègre ActivityPub. Le CMS de blogging professionnel devient fédéré.",
|
||||
category: 'software',
|
||||
softwareId: 'ghost',
|
||||
color: '#7209B7',
|
||||
},
|
||||
{
|
||||
year: 2023,
|
||||
title: 'Bluesky ouvre au public',
|
||||
description: "Bluesky (AT Protocol) ouvre ses portes. L'écosystème des réseaux décentralisés gagne en visibilité.",
|
||||
category: 'ecosystem',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
// 2024
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Loops',
|
||||
description: "Plateforme de vidéos courtes fédérée par Mozilla. Alternative à TikTok/Instagram Reels.",
|
||||
category: 'software',
|
||||
softwareId: 'loops',
|
||||
color: '#F72585',
|
||||
},
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Fedify',
|
||||
description: "Framework TypeScript/Deno pour construire des serveurs ActivityPub. Simplifie le développement fédéré.",
|
||||
category: 'software',
|
||||
softwareId: 'fedify',
|
||||
color: '#4CC9F0',
|
||||
},
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Vernissage',
|
||||
description: "Application de galerie photo fédérée pour le Fédiverse.",
|
||||
category: 'software',
|
||||
softwareId: 'vernissage',
|
||||
color: '#F8961E',
|
||||
},
|
||||
{
|
||||
year: 2024,
|
||||
title: 'Nostr — Croissance explosive',
|
||||
description: "Nostr atteint des millions d'utilisateurs. Damus, Amethyst, Primal et d'autres clients dominent. Bitcoin Lightning s'intègre pour les zaps.",
|
||||
category: 'ecosystem',
|
||||
color: '#F7931A',
|
||||
},
|
||||
// 2025
|
||||
{
|
||||
year: 2025,
|
||||
title: 'Interconnexion Nostr ↔ ActivityPub',
|
||||
description: "Les premiers ponts bidirectionnels entre Nostr et ActivityPub voient le jour. Les deux écosystèmes communiquent.",
|
||||
category: 'milestone',
|
||||
color: '#F7931A',
|
||||
},
|
||||
{
|
||||
year: 2025,
|
||||
title: 'SimpleX Chat — v6.0',
|
||||
description: "SimpleX atteint la v6.0 avec des groupes chiffrés de grande taille et des appels vidéo. Alternative mature à Signal et WhatsApp.",
|
||||
category: 'software',
|
||||
softwareId: 'simplexchat',
|
||||
color: '#06D6A0',
|
||||
},
|
||||
// 2026
|
||||
{
|
||||
year: 2026,
|
||||
title: 'Le Grand Atlas du Fédiverse v4.0',
|
||||
description: "Publication de cette cartographie complète recensant 55+ logiciels, 12 protocoles et 18 ans d'histoire du réseau fédéré.",
|
||||
category: 'milestone',
|
||||
color: '#F72585',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface Software {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
protocols: string[];
|
||||
licence: string;
|
||||
language: string;
|
||||
site: string;
|
||||
github: string;
|
||||
etat: 'Actif' | 'Maintenance' | 'Experimental' | 'Historique';
|
||||
compatibiliteAP: 'Pleine' | 'Partielle' | 'Extension' | 'Aucune' | 'En développement';
|
||||
version?: string;
|
||||
createdYear?: number;
|
||||
}
|
||||
|
||||
export interface Protocol {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'fediverse' | 'connexe' | 'voisin';
|
||||
statut: 'Actif' | 'Historique' | 'En développement';
|
||||
site?: string;
|
||||
rfc?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
group: number;
|
||||
val: number;
|
||||
description: string;
|
||||
category: string;
|
||||
color: string;
|
||||
etat: string;
|
||||
compatibiliteAP: string;
|
||||
licence: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
z?: number;
|
||||
vx?: number;
|
||||
vy?: number;
|
||||
vz?: number;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
fz?: number | null;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string | NetworkNode;
|
||||
target: string | NetworkNode;
|
||||
type: 'strong' | 'weak';
|
||||
}
|
||||
|
||||
export interface NetworkData {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Dictionnaire français — locale de référence.
|
||||
Structure de clés partagée avec gcf.ts (fallback automatique vers fr). */
|
||||
|
||||
export const fr = {
|
||||
a11y: {
|
||||
skipToContent: 'Aller au contenu principal'
|
||||
},
|
||||
nav: {
|
||||
map: 'Carte',
|
||||
protocols: 'Protocoles',
|
||||
timeline: 'Chronologie',
|
||||
catalog: 'Logiciels',
|
||||
nostr: 'Nostr',
|
||||
simplex: 'SimpleX',
|
||||
main: 'Navigation principale',
|
||||
openMenu: 'Ouvrir le menu',
|
||||
closeMenu: 'Fermer le menu'
|
||||
},
|
||||
header: {
|
||||
home: "Le Grand Atlas du Fédiverse — retour à l'accueil",
|
||||
backToAtlas: "Retour à l'Atlas",
|
||||
install: "Installer l'application",
|
||||
installed: 'Application installée'
|
||||
},
|
||||
offline: {
|
||||
indicator: 'Hors connexion — vous naviguez sur la copie locale'
|
||||
},
|
||||
common: {
|
||||
close: 'Fermer',
|
||||
loading: 'Chargement…',
|
||||
search: 'Rechercher',
|
||||
clear: 'Effacer',
|
||||
resetFilters: 'Réinitialiser les filtres',
|
||||
emptyResults: 'Aucun résultat pour ces critères',
|
||||
officialSite: 'Site officiel',
|
||||
sourceCode: 'Code source',
|
||||
backToTop: 'Retour en haut de page'
|
||||
},
|
||||
catalog: {
|
||||
upToDate: 'Vous êtes à jour',
|
||||
upToDateCreole: 'ou pa manké ayen',
|
||||
results: '{count} résultat(s)'
|
||||
},
|
||||
shortcuts: {
|
||||
title: 'Raccourcis clavier',
|
||||
open: 'Afficher les raccourcis clavier',
|
||||
search: 'Aller à la recherche du catalogue',
|
||||
help: 'Afficher cette aide',
|
||||
next: 'Logiciel suivant dans le catalogue',
|
||||
prev: 'Logiciel précédent dans le catalogue',
|
||||
close: 'Fermer la fenêtre'
|
||||
},
|
||||
footer: {
|
||||
baseline:
|
||||
'Une cartographie interactive et communautaire du réseau fédéré décentralisé. Maintenue à jour par et pour la communauté francophone.',
|
||||
madeIn: 'Fait avec ❤ en Guadeloupe',
|
||||
project: 'Projet',
|
||||
about: 'À propos',
|
||||
license: 'Licence CC BY-SA',
|
||||
source: 'Code source',
|
||||
api: 'Données (JSON)',
|
||||
resources: 'Ressources',
|
||||
community: 'Communauté',
|
||||
rights: 'Le Grand Atlas du Fédiverse © 2026 — Sous licence CC BY-SA 4.0',
|
||||
credits:
|
||||
'Inspiré par Per Axbom (axbom.com/fediverse) · Données mises à jour régulièrement par la communauté',
|
||||
localeLabel: 'Langue de l’interface'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/* Dictionnaire créole guadeloupéen (gcf).
|
||||
Traduction progressive : les clés absentes retombent sur le français. */
|
||||
|
||||
import type { fr } from './fr';
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
||||
};
|
||||
|
||||
export const gcf: DeepPartial<typeof fr> = {
|
||||
a11y: {
|
||||
skipToContent: 'Alé o kontni prensipal'
|
||||
},
|
||||
nav: {
|
||||
map: 'Kat',
|
||||
protocols: 'Pwotokòl',
|
||||
timeline: 'Kronoloji',
|
||||
catalog: 'Lojisyèl',
|
||||
nostr: 'Nostr',
|
||||
simplex: 'SimpleX',
|
||||
main: 'Navigasyon prensipal',
|
||||
openMenu: 'Ouvri meni an',
|
||||
closeMenu: 'Fèmé meni an'
|
||||
},
|
||||
header: {
|
||||
home: 'Gran Atlas Fédiverse — tounen aksèy',
|
||||
backToAtlas: 'Tounen an Atlas la',
|
||||
install: 'Enstalé aplikasyon an',
|
||||
installed: 'Aplikasyon enstalé'
|
||||
},
|
||||
offline: {
|
||||
indicator: 'San kònèksyon — w ap gadé kopi lokal la'
|
||||
},
|
||||
common: {
|
||||
close: 'Fèmé',
|
||||
loading: 'Sa ap chajé…',
|
||||
search: 'Chèchè',
|
||||
clear: 'Éfasé',
|
||||
resetFilters: 'Rémèt filtr yo a zéwo',
|
||||
emptyResults: 'Pa ni pyès rézilta pou kritè sa-yo',
|
||||
officialSite: 'Sit ofisyèl',
|
||||
sourceCode: 'Kòd sous',
|
||||
backToTop: 'Tounen anlè paj la'
|
||||
},
|
||||
catalog: {
|
||||
upToDate: 'Ou pa manké ayen',
|
||||
upToDateCreole: 'vous êtes à jour',
|
||||
results: '{count} rézilta'
|
||||
},
|
||||
shortcuts: {
|
||||
title: 'Rakoursi klavyé',
|
||||
open: 'Afiché rakoursi klavyé yo',
|
||||
search: 'Alé ba rachech katalòg la',
|
||||
help: 'Afiché èd sa a',
|
||||
next: 'Lojisyèl swivan an katalòg la',
|
||||
prev: 'Lojisyèl avan an katalòg la',
|
||||
close: 'Fèmé fenèt la'
|
||||
},
|
||||
footer: {
|
||||
baseline:
|
||||
'Yon kat entèaktif é kominotèr di rézo fédéré désantralizé a. Kominoté frankofonn ka tjouwen li ajou.',
|
||||
madeIn: 'Fè ké lanmou an Gwadloup',
|
||||
project: 'Pwojè',
|
||||
about: 'Konsènan',
|
||||
license: 'Lisans CC BY-SA',
|
||||
source: 'Kòd sous',
|
||||
api: 'Done (JSON)',
|
||||
resources: 'Résous',
|
||||
community: 'Kominoté',
|
||||
rights: 'Gran Atlas Fédiverse © 2026 — Anba lisans CC BY-SA 4.0',
|
||||
credits:
|
||||
'Enspiré pa Per Axbom (axbom.com/fediverse) · Done yo ka mèt ajou souvan pa kominoté a',
|
||||
localeLabel: 'Lang entèfas la'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/* i18n réactif (runes Svelte 5) — préparation bilinguisme FR / créole.
|
||||
Usage dans un composant : `import { t } from '$lib/i18n/i18n.svelte';`
|
||||
puis `{t('nav.map')}` dans le template (réactif au changement de locale). */
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { fr } from './fr';
|
||||
import { gcf } from './gcf';
|
||||
|
||||
export const LOCALES = [
|
||||
{ id: 'fr', label: 'Français' },
|
||||
{ id: 'gcf', label: 'Kréyòl' }
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof LOCALES)[number]['id'];
|
||||
|
||||
const STORAGE_KEY = 'atlas-locale';
|
||||
|
||||
const dicts: Record<Locale, unknown> = { fr, gcf };
|
||||
|
||||
let locale = $state<Locale>('fr');
|
||||
|
||||
function lookup(dict: unknown, path: string): string | undefined {
|
||||
let node: unknown = dict;
|
||||
for (const part of path.split('.')) {
|
||||
if (node === null || typeof node !== 'object') return undefined;
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof node === 'string' ? node : undefined;
|
||||
}
|
||||
|
||||
/** Traduit une clé (ex. 'nav.map'). Retombe sur le français si absente.
|
||||
Supporte l'interpolation `{name}` via params. */
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
// La lecture de `locale` rend l'appel réactif dans les templates Svelte 5.
|
||||
const raw = lookup(dicts[locale], key) ?? lookup(fr, key) ?? key;
|
||||
if (!params) return raw;
|
||||
return raw.replaceAll(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`));
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return locale;
|
||||
}
|
||||
|
||||
export function setLocale(next: Locale): void {
|
||||
locale = next;
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** À appeler une fois côté client (layout racine). */
|
||||
export function initLocale(): void {
|
||||
if (!browser) return;
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'fr' || saved === 'gcf') {
|
||||
locale = saved;
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Garde commune : ne pas intercepter les raccourcis pendant la saisie. */
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return (
|
||||
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* État PWA partagé (runes) — capture beforeinstallprompt, détection installée. */
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
let deferredPrompt = $state<BeforeInstallPromptEvent | null>(null);
|
||||
let installed = $state(false);
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/** À appeler une fois côté client (layout racine). */
|
||||
export function initPwa(): void {
|
||||
if (!browser || initialized) return;
|
||||
initialized = true;
|
||||
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
installed = true;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event as BeforeInstallPromptEvent;
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function canInstall(): boolean {
|
||||
return deferredPrompt !== null;
|
||||
}
|
||||
|
||||
export function isInstalled(): boolean {
|
||||
return installed;
|
||||
}
|
||||
|
||||
/** Déclenche le prompt d'installation natif. Retourne true si accepté. */
|
||||
export async function promptInstall(): Promise<boolean> {
|
||||
if (!deferredPrompt) return false;
|
||||
const promptEvent = deferredPrompt;
|
||||
deferredPrompt = null;
|
||||
await promptEvent.prompt();
|
||||
const { outcome } = await promptEvent.userChoice;
|
||||
return outcome === 'accepted';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
|
||||
import ShortcutsHelp from '$lib/components/ShortcutsHelp.svelte';
|
||||
import { initLocale, t } from '$lib/i18n/i18n.svelte';
|
||||
import { initPwa } from '$lib/pwa.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
// Marqueur de démarrage pour le script de diagnostic de app.html
|
||||
document.documentElement.dataset.js = 'ok';
|
||||
initLocale();
|
||||
initPwa();
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main">{t('a11y.skipToContent')}</a>
|
||||
<Header />
|
||||
<main id="main" tabindex="-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
<OfflineIndicator />
|
||||
<ShortcutsHelp />
|
||||
@@ -0,0 +1,6 @@
|
||||
export const prerender = true;
|
||||
|
||||
// Hébergement statique (nginx YunoHost) : chaque page est générée dans son
|
||||
// dossier (nostr/index.html) — structure la plus sûre pour du statique,
|
||||
// sans dépendre de règles de réécriture.
|
||||
export const trailingSlash = 'always';
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import HeroSection from '$lib/components/HeroSection.svelte';
|
||||
import NetworkGraph from '$lib/components/NetworkGraph.svelte';
|
||||
import ProtocolSection from '$lib/components/ProtocolSection.svelte';
|
||||
import TimelineSection from '$lib/components/TimelineSection.svelte';
|
||||
import SoftwareCatalog from '$lib/components/SoftwareCatalog.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Le Grand Atlas du Fédiverse — 2026</title>
|
||||
</svelte:head>
|
||||
|
||||
<HeroSection />
|
||||
<NetworkGraph />
|
||||
<ProtocolSection />
|
||||
<TimelineSection />
|
||||
<SoftwareCatalog />
|
||||
@@ -0,0 +1,702 @@
|
||||
<script lang="ts">
|
||||
import { softwares } from '$lib/data';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import Icon from '$lib/components/pages/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/pages/Icon.svelte';
|
||||
import FeatureGrid from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import type { FeatureItem } from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import StepsGrid from '$lib/components/pages/StepsGrid.svelte';
|
||||
import type { StepItem } from '$lib/components/pages/StepsGrid.svelte';
|
||||
import CompareTable from '$lib/components/pages/CompareTable.svelte';
|
||||
import type { CompareRow } from '$lib/components/pages/CompareTable.svelte';
|
||||
|
||||
const clients = softwares.filter((s) => s.category === 'nostr');
|
||||
|
||||
let openId = $state<string | null>(null);
|
||||
|
||||
function toggle(id: string): void {
|
||||
openId = openId === id ? null : id;
|
||||
}
|
||||
|
||||
const steps: StepItem[] = [
|
||||
{
|
||||
step: '01',
|
||||
icon: 'key',
|
||||
title: 'Générez vos clés',
|
||||
description:
|
||||
"Votre client Nostr crée une paire de clés (npub + nsec). Pas d'inscription, pas d'email. Vos clés SONT votre identité."
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
icon: 'server',
|
||||
title: 'Connectez des relays',
|
||||
description:
|
||||
'Choisissez des relays publics ou hébergez le vôtre. Vos messages sont publiés sur tous les relays que vous sélectionnez.'
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
icon: 'message',
|
||||
title: 'Communiquez',
|
||||
description:
|
||||
"Publiez des notes, suivez d'autres utilisateurs, envoyez des messages privés chiffrés. Tout fonctionne sans serveur central."
|
||||
}
|
||||
];
|
||||
|
||||
const features: FeatureItem[] = [
|
||||
{
|
||||
icon: 'key',
|
||||
title: 'Clés cryptographiques',
|
||||
description:
|
||||
"Pas de compte, pas d'email, pas de mot de passe. Votre identité est une paire de clés (npub/nsec). Vous la contrôlez entièrement."
|
||||
},
|
||||
{
|
||||
icon: 'server',
|
||||
title: "Relays, pas d'instances",
|
||||
description:
|
||||
'Pas de serveurs à administrer. Les relays sont de simples relais de messages. Vous en choisissez plusieurs, vous pouvez en changer à tout moment.'
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Chiffrement de bout en bout',
|
||||
description:
|
||||
'Les messages DM sont chiffrés côté client avec les clés publiques du destinataire. Même les relays ne peuvent pas lire vos messages.'
|
||||
},
|
||||
{
|
||||
icon: 'bitcoin',
|
||||
title: 'Lightning Zaps',
|
||||
description:
|
||||
'Intégration native avec Bitcoin Lightning Network. Envoyez des satoshis (zaps) en récompense de contenus. Économie circulaire intégrée.'
|
||||
},
|
||||
{
|
||||
icon: 'radio',
|
||||
title: 'Censure-résistant',
|
||||
description:
|
||||
'Pas de point central de contrôle. Vos messages existent sur de multiples relays simultanément. Impossible de vous bannir du réseau.'
|
||||
},
|
||||
{
|
||||
icon: 'globe',
|
||||
title: 'Interopérabilité totale',
|
||||
description:
|
||||
'Tous les clients Nostr peuvent lire et publier sur tous les relays. Pas de fragmentation comme entre instances ActivityPub.'
|
||||
}
|
||||
];
|
||||
|
||||
const comparison: CompareRow[] = [
|
||||
{ aspect: 'Architecture', values: ['Instances fédérées (serveurs)', 'Relays décentralisés (pas de serveurs)'] },
|
||||
{ aspect: 'Identité', values: ['@utilisateur@instance.social', 'Clé publique (npub1...)'] },
|
||||
{ aspect: 'Chiffrement', values: ['TLS + HTTP Signatures (serveur)', 'Chiffrement client-side (NIP-04)'] },
|
||||
{ aspect: 'Résilience', values: ['Instance = point de défaillance', 'Multi-relays, pas de SPOF'] },
|
||||
{ aspect: 'Découverte', values: ['WebFinger + annuaires', 'Relays + moteurs de recherche'] },
|
||||
{ aspect: 'Médias', values: ["Stockage sur l'instance", 'Blossom / NIP-96 (décentralisé)'] },
|
||||
{ aspect: 'Monétisation', values: ['Donations, sponsoring', 'Lightning Zaps intégrés'] },
|
||||
{ aspect: 'Modération', values: ["Administrateurs d'instance", 'Client-side, choix des relays'] }
|
||||
];
|
||||
|
||||
const simplexPoints: { icon: IconName; title: string; description: string }[] = [
|
||||
{
|
||||
icon: 'user-x',
|
||||
title: "Pas d'identifiants",
|
||||
description:
|
||||
"Aucun numéro de téléphone, email ou nom d'utilisateur. Vos contacts ne se connaissent pas entre eux."
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Chiffrement E2E',
|
||||
description:
|
||||
'Chiffrement de bout en bout par défaut. Même les serveurs SimpleX ne peuvent pas voir vos messages.'
|
||||
},
|
||||
{
|
||||
icon: 'globe',
|
||||
title: 'Décentralisé',
|
||||
description:
|
||||
'Utilisez les serveurs publics ou hébergez les vôtres. Les utilisateurs peuvent utiliser différents serveurs.'
|
||||
},
|
||||
{
|
||||
icon: 'github',
|
||||
title: 'Open source',
|
||||
description:
|
||||
'Code open source audité. Client en Haskell, applications iOS, Android et desktop.'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Nostr — Le Grand Atlas du Fédiverse</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Nostr, protocole ouvert et résistant à la censure : clés cryptographiques, relays, 18 clients à découvrir et comparaison avec ActivityPub."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page" style:--page-accent="var(--accent-3)">
|
||||
<!-- Hero -->
|
||||
<section class="hero" aria-labelledby="nostr-title">
|
||||
<div class="center stack hero-inner">
|
||||
<p class="badge">
|
||||
<Icon name="zap" size={16} />
|
||||
<span>Écosystème voisin</span>
|
||||
</p>
|
||||
<h1 id="nostr-title">
|
||||
Nostr :
|
||||
<span class="gradient-text">Notes and Other Stuff<br />Transmitted by Relays</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Un protocole ouvert, simple et résistant à la censure pour créer des réseaux sociaux
|
||||
décentralisés. Pas d'instances, pas de serveurs — juste des <strong>clés</strong> et des
|
||||
<strong>relays</strong>.
|
||||
</p>
|
||||
<div class="cluster cta">
|
||||
<a class="btn btn-primary" href="https://nostr.com/" target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="external" size={16} />
|
||||
Découvrir Nostr
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost"
|
||||
href="https://github.com/nostr-protocol/nips"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={16} />
|
||||
NIPs (specs)
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comment ça marche -->
|
||||
<section class="section section-alt" aria-labelledby="how-title">
|
||||
<div class="center stack">
|
||||
<h2 id="how-title" class="section-title">Comment ça <span class="accent-text">marche</span> ?</h2>
|
||||
<StepsGrid items={steps} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pourquoi Nostr -->
|
||||
<section class="section" aria-labelledby="why-title">
|
||||
<div class="center stack">
|
||||
<h2 id="why-title" class="section-title">Pourquoi <span class="accent-text">Nostr</span> ?</h2>
|
||||
<FeatureGrid items={features} min="19rem" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clients Nostr -->
|
||||
<section class="section section-alt" aria-labelledby="clients-title">
|
||||
<div class="center stack">
|
||||
<h2 id="clients-title" class="section-title">
|
||||
Les clients <span class="accent-text">Nostr</span>
|
||||
</h2>
|
||||
<p class="section-lede">
|
||||
Écosystème riche de clients web, mobiles et desktop. Choisissez celui qui correspond à vos
|
||||
besoins.
|
||||
</p>
|
||||
|
||||
<div class="clients-cq">
|
||||
<ul class="grid clients-grid" role="list" style:--grid-min="19rem">
|
||||
{#each clients as sw (sw.id)}
|
||||
{@const open = openId === sw.id}
|
||||
<li class="client-card" class:open>
|
||||
<h3 class="client-heading">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="client-details-{sw.id}"
|
||||
id="client-btn-{sw.id}"
|
||||
onclick={() => toggle(sw.id)}
|
||||
>
|
||||
<span class="client-initial" aria-hidden="true">{sw.name.charAt(0)}</span>
|
||||
<span class="client-names">
|
||||
<span class="client-name">{sw.name}</span>
|
||||
<span class="client-lang">{sw.language}</span>
|
||||
</span>
|
||||
<span class="client-chevron" class:open>
|
||||
<Icon name="chevron" size={18} />
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<p class="client-desc">{sw.description}</p>
|
||||
<p class="client-etat"><span class="etat-badge">{sw.etat}</span></p>
|
||||
{#if open}
|
||||
<div
|
||||
class="client-details"
|
||||
id="client-details-{sw.id}"
|
||||
role="region"
|
||||
aria-labelledby="client-btn-{sw.id}"
|
||||
>
|
||||
<dl class="client-facts">
|
||||
<div>
|
||||
<dt>Licence</dt>
|
||||
<dd>{sw.licence}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Créé en</dt>
|
||||
<dd>{sw.createdYear ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="cluster">
|
||||
<a
|
||||
class="btn btn-primary btn-small"
|
||||
href={sw.site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="external" size={14} />
|
||||
{t('common.officialSite')}
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost btn-small"
|
||||
href={sw.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={14} />
|
||||
{t('common.sourceCode')}
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tableau comparatif -->
|
||||
<section class="section" aria-labelledby="compare-title">
|
||||
<div class="center stack" style:--center-max="60rem">
|
||||
<h2 id="compare-title" class="section-title">
|
||||
ActivityPub <span class="vs">vs</span> <span class="accent-text">Nostr</span>
|
||||
</h2>
|
||||
<CompareTable
|
||||
caption="Deux approches différentes de la décentralisation sociale. L'une n'exclut pas l'autre — des ponts sont en construction."
|
||||
columns={['ActivityPub (Fédiverse)', 'Nostr']}
|
||||
rows={comparison}
|
||||
highlight={1}
|
||||
ariaLabel="Tableau comparatif ActivityPub et Nostr — faire défiler horizontalement si nécessaire"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Encart SimpleX Chat -->
|
||||
<section class="section section-deep" aria-labelledby="simplex-title">
|
||||
<div class="center" style:--center-max="60rem">
|
||||
<article class="simplex-panel stack">
|
||||
<header class="simplex-head">
|
||||
<span class="simplex-icon"><Icon name="shield" size={24} /></span>
|
||||
<div>
|
||||
<p class="kicker">Alternative / Complément</p>
|
||||
<h2 id="simplex-title">SimpleX Chat</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
<strong>SimpleX Chat</strong> est une messagerie décentralisée de nouvelle génération qui prend
|
||||
une approche radicalement différente :
|
||||
<strong>pas d'identifiants utilisateur du tout</strong>. Contrairement au Fédiverse (où vous
|
||||
avez @utilisateur@instance) ou à Nostr (où vous avez une clé publique), SimpleX n'a aucun
|
||||
identifiant global. Chaque contact est un lien unique, chaque appareil est indépendant.
|
||||
</p>
|
||||
|
||||
<ul class="grid simplex-points" role="list" style:--grid-min="16rem">
|
||||
{#each simplexPoints as point (point.title)}
|
||||
<li class="simplex-point">
|
||||
<h3>
|
||||
<Icon name={point.icon} size={16} />
|
||||
{point.title}
|
||||
</h3>
|
||||
<p>{point.description}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="cluster">
|
||||
<a
|
||||
class="btn btn-simplex"
|
||||
href="https://simplex.chat/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="external" size={16} />
|
||||
Site officiel
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost"
|
||||
href="https://github.com/simplex-chat/simplex-chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={16} />
|
||||
Code source
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<p class="simplex-badges">
|
||||
<span class="mono">AGPL-3.0</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="mono">Haskell</span>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
--page-accent: var(--accent-3);
|
||||
--page-accent-text: var(--accent-3-text);
|
||||
--page-accent-strong: var(--accent-3-strong);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
|
||||
.hero {
|
||||
padding-block-start: calc(var(--header-h) + var(--space-6));
|
||||
padding-block-end: var(--space-6);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
--stack-gap: var(--space-4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid color-mix(in oklch, var(--page-accent) 40%, transparent);
|
||||
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-inline-size: 22ch;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent-3-text), var(--accent-2-text));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-2);
|
||||
max-inline-size: 58ch;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.cta {
|
||||
justify-content: center;
|
||||
--cluster-gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
.section-alt {
|
||||
background: var(--ocean);
|
||||
}
|
||||
|
||||
.section-deep {
|
||||
background: var(--abyss);
|
||||
}
|
||||
|
||||
.section > .center {
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.vs {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.section-lede {
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ---------- Boutons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-small);
|
||||
text-decoration: none;
|
||||
transition: transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--page-accent-strong);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
/* ---------- Accordéon clients ---------- */
|
||||
|
||||
.clients-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.clients-grid {
|
||||
--grid-min: 100%;
|
||||
}
|
||||
|
||||
@container (min-width: 40rem) {
|
||||
.clients-grid {
|
||||
--grid-min: 19rem;
|
||||
}
|
||||
}
|
||||
|
||||
.client-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
transition: border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.client-card.open {
|
||||
border-color: color-mix(in oklch, var(--page-accent) 50%, transparent);
|
||||
}
|
||||
|
||||
.client-heading {
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.client-heading button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.client-initial {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.5rem;
|
||||
block-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-1);
|
||||
background: color-mix(in oklch, var(--page-accent) 16%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.client-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.client-heading button:hover .client-name,
|
||||
.client-card.open .client-name {
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.client-lang {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.client-chevron {
|
||||
margin-inline-start: auto;
|
||||
color: var(--muted);
|
||||
transition: transform var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.client-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.client-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.client-etat {
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.etat-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.client-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-block-start: var(--space-3);
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.client-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.client-facts > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.client-facts dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.client-facts dd {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Encart SimpleX ---------- */
|
||||
|
||||
.simplex-panel {
|
||||
--stack-gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-3);
|
||||
}
|
||||
|
||||
.simplex-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.simplex-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--accent-4-text);
|
||||
background: color-mix(in oklch, var(--accent-4) 14%, transparent);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.simplex-panel p {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.simplex-panel p strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.simplex-point {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid color-mix(in oklch, var(--accent-4) 18%, transparent);
|
||||
background: color-mix(in oklch, var(--accent-4) 8%, transparent);
|
||||
}
|
||||
|
||||
.simplex-point h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-1);
|
||||
color: var(--accent-4-text);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.simplex-point p {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.btn-simplex {
|
||||
background: var(--accent-4-strong);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.simplex-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,497 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/pages/Icon.svelte';
|
||||
import FeatureGrid from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import type { FeatureItem } from '$lib/components/pages/FeatureGrid.svelte';
|
||||
import StepsGrid from '$lib/components/pages/StepsGrid.svelte';
|
||||
import type { StepItem } from '$lib/components/pages/StepsGrid.svelte';
|
||||
import CompareTable from '$lib/components/pages/CompareTable.svelte';
|
||||
import type { CompareRow } from '$lib/components/pages/CompareTable.svelte';
|
||||
import type { IconName } from '$lib/components/pages/Icon.svelte';
|
||||
|
||||
const pageStats = [
|
||||
{ value: '1M+', label: 'Téléchargements' },
|
||||
{ value: '100+', label: 'Pays' },
|
||||
{ value: 'AGPL-3.0', label: 'Licence' },
|
||||
{ value: '100%', label: 'Open source' }
|
||||
];
|
||||
|
||||
const steps: StepItem[] = [
|
||||
{
|
||||
step: '01',
|
||||
icon: 'key',
|
||||
title: "L'application crée vos clés",
|
||||
description:
|
||||
'Votre appareil génère des clés cryptographiques. Aucune information personnelle requise.'
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
icon: 'users',
|
||||
title: 'Connectez via des liens',
|
||||
description:
|
||||
"Chaque contact utilise un lien d'invitation unique et jetable. Partagez par QR code, URL ou message."
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
icon: 'message',
|
||||
title: 'Communiquez en privé',
|
||||
description:
|
||||
'Messages, groupes et appels chiffrés de bout en bout. Vos métadonnées restent privées.'
|
||||
}
|
||||
];
|
||||
|
||||
const features: FeatureItem[] = [
|
||||
{
|
||||
icon: 'user-x',
|
||||
title: 'Zéro identifiant',
|
||||
description:
|
||||
"Aucun numéro de téléphone, email, nom d'utilisateur ou mot de passe. Vos contacts ne se connaissent pas entre eux."
|
||||
},
|
||||
{
|
||||
icon: 'eye-off',
|
||||
title: 'Métadonnées minimisées',
|
||||
description:
|
||||
'SimpleX ne collecte pas vos métadonnées. Pas de serveur central qui connaît votre réseau social.'
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Chiffrement E2E de nouvelle génération',
|
||||
description:
|
||||
'Double Ratchet Algorithm (comme Signal) + chiffrement post-quantique. Même les serveurs ne peuvent pas lire vos messages.'
|
||||
},
|
||||
{
|
||||
icon: 'server-off',
|
||||
title: 'Sans serveur de métadonnées',
|
||||
description:
|
||||
"Contrairement à Signal ou WhatsApp, il n'y a pas de serveur central qui connaît qui parle avec qui."
|
||||
},
|
||||
{
|
||||
icon: 'fingerprint',
|
||||
title: 'Connexions anonymes',
|
||||
description:
|
||||
"Chaque contact utilise un lien d'invitation unique jetable. Votre identité n'est jamais révélée au serveur."
|
||||
},
|
||||
{
|
||||
icon: 'globe',
|
||||
title: 'Réseau décentralisé',
|
||||
description:
|
||||
'Utilisez les serveurs publics gratuits ou hébergez le vôtre. Aucun point unique de défaillance.'
|
||||
},
|
||||
{
|
||||
icon: 'message',
|
||||
title: 'Groupes de grande taille',
|
||||
description:
|
||||
'Groupes chiffrés de plusieurs centaines de membres avec administration décentralisée.'
|
||||
},
|
||||
{
|
||||
icon: 'phone',
|
||||
title: 'Appels vocaux et vidéo',
|
||||
description:
|
||||
'Appels chiffrés de bout en bout directement entre appareils, sans passer par un serveur.'
|
||||
}
|
||||
];
|
||||
|
||||
const comparison: CompareRow[] = [
|
||||
{
|
||||
aspect: 'Identifiants',
|
||||
values: ['Aucun', 'Numéro de téléphone', 'Numéro de téléphone', "Nom d'utilisateur"]
|
||||
},
|
||||
{
|
||||
aspect: 'Chiffrement',
|
||||
values: ['Double Ratchet + PQ', 'Double Ratchet', 'Signal Protocol', 'Olm/Megolm']
|
||||
},
|
||||
{
|
||||
aspect: 'Métadonnées',
|
||||
values: ['Minimisées', 'Collectées (contacts)', 'Collectées (Meta)', 'Sur le serveur']
|
||||
},
|
||||
{
|
||||
aspect: 'Serveur',
|
||||
values: ['Sans métadonnées', 'Central (Signal LLC)', 'Central (Meta)', 'Instance choisie']
|
||||
},
|
||||
{
|
||||
aspect: 'Code source',
|
||||
values: ['AGPL-3.0 (100%)', 'GPL-3.0 (client)', 'Fermé', 'Apache-2.0']
|
||||
},
|
||||
{
|
||||
aspect: 'Fédération',
|
||||
values: ['Non (intentionnel)', 'Non', 'Non', 'Oui (fédéré)']
|
||||
}
|
||||
];
|
||||
|
||||
const useCases: { icon: IconName; title: string; description: string }[] = [
|
||||
{
|
||||
icon: 'eye-off',
|
||||
title: 'Journalisme',
|
||||
description: 'Protection des sources avec des connexions anonymes et sans métadonnées.'
|
||||
},
|
||||
{
|
||||
icon: 'shield',
|
||||
title: 'Activisme',
|
||||
description: 'Communication résistante à la surveillance étatique ou corporative.'
|
||||
},
|
||||
{
|
||||
icon: 'users',
|
||||
title: 'Communautés',
|
||||
description: 'Groupes privés avec administration décentralisée et grande capacité.'
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
title: 'Entreprise',
|
||||
description: 'Messagerie interne auto-hébergée avec confidentialité garantie.'
|
||||
}
|
||||
];
|
||||
|
||||
const chips = ['Messagerie privée', 'Sans métadonnées', 'Complément Fediverse', 'Chiffrement PQ'];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SimpleX Chat — Le Grand Atlas du Fédiverse</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="SimpleX Chat, la première messagerie sans identifiants d'utilisateur : chiffrement de bout en bout post-quantique, métadonnées minimisées, comparaison avec Signal, WhatsApp et Matrix."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<!-- Hero -->
|
||||
<section class="hero" aria-labelledby="simplex-title">
|
||||
<div class="center stack hero-inner">
|
||||
<p class="badge">
|
||||
<Icon name="shield" size={16} />
|
||||
<span>Messagerie de nouvelle génération</span>
|
||||
</p>
|
||||
<h1 id="simplex-title">La messagerie <span class="accent-text">sans identifiants</span></h1>
|
||||
<p class="lede">
|
||||
SimpleX Chat est la première messagerie au monde
|
||||
<strong>sans identifiants d'utilisateur</strong>. Pas de numéro de téléphone, pas d'email,
|
||||
pas de nom d'utilisateur. Uniquement des connexions anonymes avec chiffrement de bout en
|
||||
bout post-quantique.
|
||||
</p>
|
||||
<div class="cluster cta">
|
||||
<a class="btn btn-primary" href="https://simplex.chat/" target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="external" size={16} />
|
||||
Site officiel
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost"
|
||||
href="https://github.com/simplex-chat/simplex-chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="github" size={16} />
|
||||
Code source
|
||||
<span class="visually-hidden">(nouvel onglet)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="grid hero-stats" role="list" style:--grid-min="9rem">
|
||||
{#each pageStats as stat (stat.label)}
|
||||
<li class="stat-card">
|
||||
<span class="stat-value">{stat.value}</span>
|
||||
<span class="stat-label">{stat.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comment ça marche -->
|
||||
<section class="section section-alt" aria-labelledby="how-title">
|
||||
<div class="center stack">
|
||||
<h2 id="how-title" class="section-title">Comment ça <span class="accent-text">marche</span> ?</h2>
|
||||
<StepsGrid items={steps} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pourquoi SimpleX -->
|
||||
<section class="section" aria-labelledby="why-title">
|
||||
<div class="center stack">
|
||||
<h2 id="why-title" class="section-title">Pourquoi <span class="accent-text">SimpleX</span> ?</h2>
|
||||
<FeatureGrid items={features} min="15rem" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tableau comparatif -->
|
||||
<section class="section section-alt" aria-labelledby="compare-title">
|
||||
<div class="center stack" style:--center-max="62rem">
|
||||
<h2 id="compare-title" class="section-title">
|
||||
SimpleX <span class="vs">vs</span> les autres
|
||||
</h2>
|
||||
<CompareTable
|
||||
caption="SimpleX se distingue des messageries centralisées comme des outils fédérés : aucun identifiant, métadonnées minimisées."
|
||||
columns={['SimpleX', 'Signal', 'WhatsApp', 'Matrix']}
|
||||
rows={comparison}
|
||||
highlight={0}
|
||||
ariaLabel="Tableau comparatif SimpleX, Signal, WhatsApp et Matrix — faire défiler horizontalement si nécessaire"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cas d'usage -->
|
||||
<section class="section" aria-labelledby="usecases-title">
|
||||
<div class="center stack">
|
||||
<h2 id="usecases-title" class="section-title">Cas d'<span class="accent-text">usage</span></h2>
|
||||
<div class="usecases-cq">
|
||||
<ul class="grid" role="list" style:--grid-min="15rem">
|
||||
{#each useCases as useCase (useCase.title)}
|
||||
<li class="usecase-card">
|
||||
<span class="usecase-icon"><Icon name={useCase.icon} size={24} /></span>
|
||||
<h3>{useCase.title}</h3>
|
||||
<p>{useCase.description}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SimpleX et le Fédiverse -->
|
||||
<section class="section section-deep" aria-labelledby="fediverse-title">
|
||||
<div class="center" style:--center-max="60rem">
|
||||
<article class="fediverse-panel stack">
|
||||
<h2 id="fediverse-title">SimpleX et le <span class="accent-text">Fédiverse</span></h2>
|
||||
<p>
|
||||
SimpleX Chat n'est pas fédéré au sens ActivityPub — et c'est
|
||||
<strong>intentionnel</strong>. L'absence de fédération est un choix de design pour
|
||||
préserver la confidentialité.
|
||||
</p>
|
||||
<p>
|
||||
Cependant, SimpleX complète parfaitement le Fédiverse : utilisez Mastodon/Pleroma pour
|
||||
votre présence sociale publique, et SimpleX pour vos communications privées sensibles.
|
||||
</p>
|
||||
<ul class="cluster chips" role="list">
|
||||
{#each chips as chip (chip)}
|
||||
<li class="chip mono">{chip}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
--page-accent: var(--accent-4);
|
||||
--page-accent-text: var(--accent-4-text);
|
||||
--page-accent-strong: var(--accent-4-strong);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
|
||||
.hero {
|
||||
padding-block-start: calc(var(--header-h) + var(--space-6));
|
||||
padding-block-end: var(--space-6);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
--stack-gap: var(--space-4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid color-mix(in oklch, var(--page-accent) 40%, transparent);
|
||||
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-inline-size: 22ch;
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-2);
|
||||
max-inline-size: 58ch;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.cta {
|
||||
justify-content: center;
|
||||
--cluster-gap: var(--space-3);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
inline-size: min(100%, 34rem);
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 700;
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
.section-alt {
|
||||
background: var(--ocean);
|
||||
}
|
||||
|
||||
.section-deep {
|
||||
background: var(--abyss);
|
||||
}
|
||||
|
||||
.section > .center {
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vs {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---------- Boutons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-small);
|
||||
text-decoration: none;
|
||||
transition: transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--page-accent-strong);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Cas d'usage ---------- */
|
||||
|
||||
.usecases-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.usecase-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.usecase-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--page-accent-text);
|
||||
background: color-mix(in oklch, var(--page-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.usecase-card h3 {
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.usecase-card p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 40rem) {
|
||||
.usecase-card {
|
||||
align-items: flex-start;
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Panel Fédiverse ---------- */
|
||||
|
||||
.fediverse-panel {
|
||||
--stack-gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-3);
|
||||
}
|
||||
|
||||
.fediverse-panel p {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.fediverse-panel p strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.chips {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.75rem;
|
||||
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
|
||||
color: var(--page-accent-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Service Worker — PWA offline-first.
|
||||
Stratégies :
|
||||
- Cache-First pour les assets statiques pré-cachés (build + fichiers static)
|
||||
- Network-First pour les données JSON (/data/*) et les navigations, repli cache
|
||||
- Stale-While-Revalidate pour le reste same-origin
|
||||
|
||||
Note doctrine : l'Atlas est un site de consultation sans actions mutatives
|
||||
(aucun POST/formulaire serveur) ; il n'y a donc pas de file d'attente
|
||||
d'actions hors-ligne à implémenter — la structure est prête si besoin. */
|
||||
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { build, files, prerendered, version } from '$service-worker';
|
||||
|
||||
const worker = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const PRECACHE = `atlas-precache-${version}`;
|
||||
const RUNTIME = `atlas-runtime-${version}`;
|
||||
|
||||
// Les listes $service-worker incluent le base path (/fediverse) :
|
||||
// build = chunks Vite, files = static/, prerendered = pages HTML prérendues.
|
||||
const PRECACHE_URLS = [...build, ...files, ...prerendered];
|
||||
|
||||
worker.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(PRECACHE)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => worker.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
worker.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== PRECACHE && key !== RUNTIME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
.then(() => worker.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
async function cacheFirst(request: Request): Promise<Response> {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(RUNTIME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request: Request): Promise<Response> {
|
||||
const cache = await caches.open(RUNTIME);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const url = new URL(request.url);
|
||||
// Candidates : copie de la visite, version canonique avec slash final
|
||||
// (trailingSlash: 'always' → pages en dossiers), puis la racine du scope
|
||||
// (registration.scope inclut le base path, ex. https://o-k-i.net/fediverse/).
|
||||
const candidates = [
|
||||
request.url,
|
||||
`${url.origin}${url.pathname.replace(/\/?$/, '/')}`,
|
||||
worker.registration.scope
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const hit = await caches.match(candidate);
|
||||
if (hit) return hit;
|
||||
}
|
||||
throw new Error('Hors connexion et rien en cache');
|
||||
}
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request: Request): Promise<Response> {
|
||||
const cache = await caches.open(RUNTIME);
|
||||
const cached = await cache.match(request);
|
||||
const fetched = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached as Promise<Response>);
|
||||
return cached ?? fetched;
|
||||
}
|
||||
|
||||
worker.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// Données JSON téléchargeables : fraîcheur d'abord, repli cache.
|
||||
if (url.pathname.startsWith('/data/')) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigations : réseau d'abord (contenu frais), cache hors-ligne.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets immuables pré-cachés (JS/CSS/fonts/icônes versionnés).
|
||||
if (PRECACHE_URLS.includes(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(staleWhileRevalidate(request));
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/* base.css — HTML sémantique, focus visible.
|
||||
Les @font-face sont dans app.html (chemins %sveltekit.assets% = base /fediverse). */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: var(--measure);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-2-text);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
small,
|
||||
.small {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
/* Focus visible sur tous les éléments interactifs */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-2);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-1);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* Classe utilitaire lecteur d'écran */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
inset-block-start: var(--space-2);
|
||||
inset-inline-start: var(--space-2);
|
||||
z-index: 100;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-radius: var(--radius-1);
|
||||
transform: translateY(-200%);
|
||||
transition: transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.skip-link:focus-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Éthique du mouvement : kill-switch global */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Contraste renforcé si demandé */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--border: var(--ink);
|
||||
--muted: var(--ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* layouts.css — primitives Every Layout : stack, cluster, sidebar, center, grid */
|
||||
|
||||
@layer layouts {
|
||||
/* Stack : flux vertical à rythme régulier */
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: var(--stack-gap, var(--space-3));
|
||||
}
|
||||
|
||||
/* Cluster : groupe horizontal qui s'enroule (pills, boutons, nav) */
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--cluster-gap, var(--space-2));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Sidebar : panneau latéral + contenu fluide, replie en colonne */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sidebar-gap, var(--space-4));
|
||||
}
|
||||
|
||||
.sidebar > :first-child {
|
||||
flex-basis: var(--sidebar-width, 20rem);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sidebar > :last-child {
|
||||
flex-basis: 0;
|
||||
flex-grow: 999;
|
||||
min-inline-size: var(--sidebar-content-min, 55%);
|
||||
}
|
||||
|
||||
/* Center : centrage horizontal à mesure maximale */
|
||||
.center {
|
||||
box-sizing: content-box;
|
||||
margin-inline: auto;
|
||||
max-inline-size: var(--center-max, var(--content-max));
|
||||
padding-inline: var(--center-padding, var(--space-3));
|
||||
}
|
||||
|
||||
/* Grid : répartition auto-adaptative sans media query */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--grid-gap, var(--space-3));
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(min(100%, var(--grid-min, 18rem)), 1fr)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* reset.css — reset moderne minimal */
|
||||
|
||||
@layer reset {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* jamais de font-size en px : la taille utilisateur est respectée */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
ul[role='list'],
|
||||
ol[role='list'] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* themes/default.css — tokens sémantiques du projet (couleurs de marque, surfaces, états).
|
||||
Toutes les valeurs dérivent des tokens primitifs ou de la palette oklch du projet. */
|
||||
|
||||
@layer tokens {
|
||||
:root {
|
||||
/* Surfaces profondes (identité « grand large ») */
|
||||
--surface-3: light-dark(oklch(92% 0.015 90), oklch(24% 0.03 250));
|
||||
--abyss: light-dark(oklch(96% 0.01 90), oklch(12% 0.025 255)); /* fond le plus profond #001219 */
|
||||
--ocean: light-dark(oklch(93% 0.02 90), oklch(19% 0.045 250)); /* fond intermédiaire #012A4A */
|
||||
|
||||
/* Texte */
|
||||
--ink-soft: light-dark(oklch(35% 0.03 260), oklch(82% 0.04 235)); /* #A9D6E5 en sombre, AA en clair */
|
||||
--on-accent: oklch(99% 0.01 90);
|
||||
|
||||
/* Bordures & verre */
|
||||
--border: light-dark(oklch(86% 0.015 260), oklch(34% 0.03 250));
|
||||
--border-strong: light-dark(oklch(75% 0.02 260), oklch(45% 0.04 250));
|
||||
--glass: light-dark(oklch(99% 0.005 90 / 0.75), oklch(17% 0.03 250 / 0.72));
|
||||
--glass-border: light-dark(oklch(80% 0.02 260 / 0.6), oklch(50% 0.05 250 / 0.4));
|
||||
|
||||
/* États d'activité (doublés par du texte, jamais couleur seule) */
|
||||
--status-active: light-dark(oklch(55% 0.14 165), oklch(75% 0.16 165));
|
||||
--status-maintenance: light-dark(oklch(52% 0.12 90), oklch(80% 0.15 90));
|
||||
--status-experimental: var(--accent);
|
||||
--status-historical: var(--muted);
|
||||
|
||||
/* Compatibilité ActivityPub (variantes « texte sur fond teinté », AA vérifié) */
|
||||
--compat-full: light-dark(oklch(50% 0.13 165), oklch(75% 0.16 165));
|
||||
--compat-partial: light-dark(oklch(52% 0.12 90), oklch(80% 0.15 90));
|
||||
--compat-extension: light-dark(oklch(45% 0.12 250), oklch(75% 0.13 240));
|
||||
--compat-none: light-dark(oklch(50% 0.19 350), oklch(72% 0.18 350));
|
||||
|
||||
/* Variantes « texte » des accents — AA (≥ 4.5:1) sur surfaces claires et sombres.
|
||||
Les tokens --accent* d'origine restent dédiés aux fonds/bordures décoratives. */
|
||||
--accent-text: light-dark(oklch(50% 0.19 350), oklch(72% 0.18 350));
|
||||
--accent-2-text: light-dark(oklch(45% 0.12 250), oklch(75% 0.13 240));
|
||||
--accent-3-text: light-dark(oklch(48% 0.15 70), oklch(75% 0.16 75));
|
||||
--accent-4-text: light-dark(oklch(45% 0.13 165), oklch(75% 0.16 165));
|
||||
|
||||
/* Variantes « fond » des accents pour boutons avec texte --on-accent (AA ≥ 4.5:1) */
|
||||
--accent-strong: oklch(48% 0.18 350);
|
||||
--accent-3-strong: oklch(46% 0.14 70);
|
||||
--accent-4-strong: oklch(43% 0.12 165);
|
||||
|
||||
/* Familles de polices (2 fichiers WOFF2 auto-hébergés, piles système en repli) */
|
||||
--font-display: 'Space Grotesk', 'Inter', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Rayons, ombres, halos */
|
||||
--radius-1: 0.5rem;
|
||||
--radius-2: 1rem;
|
||||
--radius-3: 1.5rem;
|
||||
--radius-pill: 999px;
|
||||
--shadow-1: 0 0.125rem 0.5rem oklch(10% 0.02 260 / 0.15);
|
||||
--shadow-2: 0 0.5rem 2rem oklch(10% 0.02 260 / 0.25);
|
||||
--glow-accent: 0 0 1.5rem oklch(55% 0.2 350 / 0.45);
|
||||
--glow-accent-2: 0 0 1.5rem oklch(70% 0.15 250 / 0.35);
|
||||
|
||||
/* Dégradés de marque (les usages sont tous du texte en background-clip :
|
||||
construit sur les variantes « texte » pour rester AA dans les deux modes) */
|
||||
--gradient-brand: linear-gradient(135deg, var(--accent-text), var(--accent-2-text));
|
||||
--gradient-abyss: linear-gradient(180deg, var(--ocean), var(--abyss));
|
||||
|
||||
/* Structure */
|
||||
--header-h: 4.5rem;
|
||||
--content-max: 75rem;
|
||||
|
||||
/* Transitions (désactivées via prefers-reduced-motion dans base.css) */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--duration-1: 150ms;
|
||||
--duration-2: 300ms;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* tokens.css — Design tokens (oklch, typo fluide, espacements)
|
||||
Méthodologie modus-vivendi : color-scheme light/dark via light-dark(). */
|
||||
|
||||
@layer tokens {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Couleurs de base */
|
||||
--ink: light-dark(oklch(22% 0.02 260), oklch(92% 0.01 260));
|
||||
--surface: light-dark(oklch(98% 0.005 90), oklch(15% 0.02 260));
|
||||
--surface-2: light-dark(oklch(95% 0.01 90), oklch(20% 0.02 260));
|
||||
--accent: oklch(55% 0.2 350); /* rose coral #F72585 */
|
||||
--accent-2: oklch(70% 0.15 250); /* bleu #4CC9F0 */
|
||||
--accent-3: oklch(65% 0.18 80); /* orange Nostr #F7931A */
|
||||
--accent-4: oklch(70% 0.15 160); /* vert SimpleX #06D6A0 */
|
||||
--muted: light-dark(oklch(45% 0.02 260), oklch(70% 0.02 260));
|
||||
|
||||
/* Typographie fluide */
|
||||
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
|
||||
--text-2: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem);
|
||||
--text-3: clamp(1.5rem, 1.3rem + 1vw, 2rem);
|
||||
--text-4: clamp(2rem, 1.8rem + 1.2vw, 3rem);
|
||||
--text-small: clamp(0.875rem, 0.85rem + 0.1vw, 0.9375rem);
|
||||
|
||||
/* Espacements */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
|
||||
/* Divers */
|
||||
--tap-target: 48px;
|
||||
--measure: 65ch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Diagnostic de démarrage — script classique externe (compatible CSP « script-src 'self' »).
|
||||
Affiche le bandeau #boot-warning si l'app SvelteKit n'a pas démarré après 8 s
|
||||
(modules bloqués, ex. ouverture en file:// ou déploiement incomplet). */
|
||||
window.setTimeout(function () {
|
||||
if (!document.documentElement.dataset.js) {
|
||||
var warning = document.getElementById('boot-warning');
|
||||
if (warning) warning.hidden = false;
|
||||
}
|
||||
}, 8000);
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"id": "social",
|
||||
"name": "Réseaux sociaux",
|
||||
"icon": "Users",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Plateformes sociales fédérées"
|
||||
},
|
||||
{
|
||||
"id": "microblog",
|
||||
"name": "Microblogging",
|
||||
"icon": "MessageSquare",
|
||||
"color": "#F9C74F",
|
||||
"description": "Micro-blogs et status updates"
|
||||
},
|
||||
{
|
||||
"id": "photo",
|
||||
"name": "Photographie",
|
||||
"icon": "Camera",
|
||||
"color": "#F8961E",
|
||||
"description": "Partage de photos et images"
|
||||
},
|
||||
{
|
||||
"id": "video",
|
||||
"name": "Vidéo",
|
||||
"icon": "Video",
|
||||
"color": "#F72585",
|
||||
"description": "Streaming et partage vidéo"
|
||||
},
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "Audio & Podcast",
|
||||
"icon": "Headphones",
|
||||
"color": "#B5179E",
|
||||
"description": "Musique, podcasts et audio"
|
||||
},
|
||||
{
|
||||
"id": "blog",
|
||||
"name": "Blogs",
|
||||
"icon": "FileText",
|
||||
"color": "#7209B7",
|
||||
"description": "Plateformes de blogging"
|
||||
},
|
||||
{
|
||||
"id": "forum",
|
||||
"name": "Forums",
|
||||
"icon": "MessageCircle",
|
||||
"color": "#3A0CA3",
|
||||
"description": "Forums et agrégateurs"
|
||||
},
|
||||
{
|
||||
"id": "events",
|
||||
"name": "Événements",
|
||||
"icon": "Calendar",
|
||||
"color": "#4361EE",
|
||||
"description": "Gestion d'événements"
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Développement",
|
||||
"icon": "Code",
|
||||
"color": "#4CC9F0",
|
||||
"description": "Forge et développement logiciel"
|
||||
},
|
||||
{
|
||||
"id": "collab",
|
||||
"name": "Collaboration",
|
||||
"icon": "FolderOpen",
|
||||
"color": "#2EC4B6",
|
||||
"description": "Outils collaboratifs et cloud"
|
||||
},
|
||||
{
|
||||
"id": "books",
|
||||
"name": "Livres",
|
||||
"icon": "BookOpen",
|
||||
"color": "#E71D36",
|
||||
"description": "Lecture et partage de livres"
|
||||
},
|
||||
{
|
||||
"id": "wiki",
|
||||
"name": "Wiki",
|
||||
"icon": "Globe",
|
||||
"color": "#662E9B",
|
||||
"description": "Encyclopédies collaboratives"
|
||||
},
|
||||
{
|
||||
"id": "messaging",
|
||||
"name": "Messagerie",
|
||||
"icon": "Send",
|
||||
"color": "#06D6A0",
|
||||
"description": "Messagerie instantanée privée et sécurisée"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"icon": "Zap",
|
||||
"color": "#F7931A",
|
||||
"description": "Écosystème Nostr — protocole voisin"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
- id: social
|
||||
name: Réseaux sociaux
|
||||
icon: Users
|
||||
color: '#4CC9F0'
|
||||
description: Plateformes sociales fédérées
|
||||
- id: microblog
|
||||
name: Microblogging
|
||||
icon: MessageSquare
|
||||
color: '#F9C74F'
|
||||
description: Micro-blogs et status updates
|
||||
- id: photo
|
||||
name: Photographie
|
||||
icon: Camera
|
||||
color: '#F8961E'
|
||||
description: Partage de photos et images
|
||||
- id: video
|
||||
name: Vidéo
|
||||
icon: Video
|
||||
color: '#F72585'
|
||||
description: Streaming et partage vidéo
|
||||
- id: audio
|
||||
name: Audio & Podcast
|
||||
icon: Headphones
|
||||
color: '#B5179E'
|
||||
description: Musique, podcasts et audio
|
||||
- id: blog
|
||||
name: Blogs
|
||||
icon: FileText
|
||||
color: '#7209B7'
|
||||
description: Plateformes de blogging
|
||||
- id: forum
|
||||
name: Forums
|
||||
icon: MessageCircle
|
||||
color: '#3A0CA3'
|
||||
description: Forums et agrégateurs
|
||||
- id: events
|
||||
name: Événements
|
||||
icon: Calendar
|
||||
color: '#4361EE'
|
||||
description: Gestion d'événements
|
||||
- id: dev
|
||||
name: Développement
|
||||
icon: Code
|
||||
color: '#4CC9F0'
|
||||
description: Forge et développement logiciel
|
||||
- id: collab
|
||||
name: Collaboration
|
||||
icon: FolderOpen
|
||||
color: '#2EC4B6'
|
||||
description: Outils collaboratifs et cloud
|
||||
- id: books
|
||||
name: Livres
|
||||
icon: BookOpen
|
||||
color: '#E71D36'
|
||||
description: Lecture et partage de livres
|
||||
- id: wiki
|
||||
name: Wiki
|
||||
icon: Globe
|
||||
color: '#662E9B'
|
||||
description: Encyclopédies collaboratives
|
||||
- id: messaging
|
||||
name: Messagerie
|
||||
icon: Send
|
||||
color: '#06D6A0'
|
||||
description: Messagerie instantanée privée et sécurisée
|
||||
- id: nostr
|
||||
name: Nostr
|
||||
icon: Zap
|
||||
color: '#F7931A'
|
||||
description: Écosystème Nostr — protocole voisin
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Le Grand Atlas du Fédiverse",
|
||||
"version": "4.0",
|
||||
"year": 2026,
|
||||
"language": "fr",
|
||||
"licence": "CC BY-SA 4.0",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"statistics": {
|
||||
"totalSoftwares": 105,
|
||||
"totalProtocols": 12,
|
||||
"totalCategories": 14,
|
||||
"activeSoftwares": 93,
|
||||
"maintenanceSoftwares": 4,
|
||||
"experimentalSoftwares": 5,
|
||||
"historicalSoftwares": 3,
|
||||
"fullAPCompatibility": 45,
|
||||
"partialAPCompatibility": 4,
|
||||
"extensionAPCompatibility": 9
|
||||
},
|
||||
"attribution": {
|
||||
"inspiredBy": "Per Axbom (axbom.com/fediverse)",
|
||||
"originalVersion": "3.0 (January 2023)",
|
||||
"maintainers": [
|
||||
"Community"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
title: Le Grand Atlas du Fédiverse
|
||||
version: '4.0'
|
||||
year: 2026
|
||||
language: fr
|
||||
licence: CC BY-SA 4.0
|
||||
lastUpdated: '2026-07-12'
|
||||
statistics:
|
||||
totalSoftwares: 105
|
||||
totalProtocols: 12
|
||||
totalCategories: 14
|
||||
activeSoftwares: 93
|
||||
maintenanceSoftwares: 4
|
||||
experimentalSoftwares: 5
|
||||
historicalSoftwares: 3
|
||||
fullAPCompatibility: 45
|
||||
partialAPCompatibility: 4
|
||||
extensionAPCompatibility: 9
|
||||
attribution:
|
||||
inspiredBy: Per Axbom (axbom.com/fediverse)
|
||||
originalVersion: 3.0 (January 2023)
|
||||
maintainers:
|
||||
- Community
|
||||
@@ -0,0 +1,97 @@
|
||||
[
|
||||
{
|
||||
"id": "activitypub",
|
||||
"name": "ActivityPub",
|
||||
"description": "Protocole de réseautage social décentralisé recommandé par le W3C. Le standard principal du Fédiverse.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"site": "https://activitypub.rocks/",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "activitystreams",
|
||||
"name": "ActivityStreams 2.0",
|
||||
"description": "Format de données pour décrire les activités sociales. Base sur laquelle repose ActivityPub.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "W3C Recommendation"
|
||||
},
|
||||
{
|
||||
"id": "webfinger",
|
||||
"name": "WebFinger",
|
||||
"description": "Protocole de découverte d'informations sur les ressources identifiées par des URI web.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 7033"
|
||||
},
|
||||
{
|
||||
"id": "httpsignatures",
|
||||
"name": "HTTP Signatures",
|
||||
"description": "Mécanisme de signature des requêtes HTTP pour l'authentification entre instances.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "IETF Draft"
|
||||
},
|
||||
{
|
||||
"id": "nodeinfo",
|
||||
"name": "NodeInfo",
|
||||
"description": "Schéma de métadonnées pour décrire les instances fédérées et leurs capacités.",
|
||||
"type": "fediverse",
|
||||
"statut": "Actif",
|
||||
"rfc": "nodeinfo.diaspora.software"
|
||||
},
|
||||
{
|
||||
"id": "zot",
|
||||
"name": "Zot / Nomad",
|
||||
"description": "Protocole de communications décentralisées avec prise en charge avancée de la privacy et du nomadisme d'identité.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://zotlabs.org/"
|
||||
},
|
||||
{
|
||||
"id": "diaspora",
|
||||
"name": "Diaspora* Protocol",
|
||||
"description": "Protocole historique du réseau Diaspora*, précurseur du Fédiverse moderne.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "ostatus",
|
||||
"name": "OStatus",
|
||||
"description": "Suite de protocoles (Atom, Salmon, WebSub) utilisée par les premières plateformes fédérées.",
|
||||
"type": "connexe",
|
||||
"statut": "Historique"
|
||||
},
|
||||
{
|
||||
"id": "matrix",
|
||||
"name": "Matrix Protocol",
|
||||
"description": "Protocole ouvert pour la communication temps réel (chat, VoIP). Interopère avec ActivityPub.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"site": "https://matrix.org/"
|
||||
},
|
||||
{
|
||||
"id": "xmpp",
|
||||
"name": "XMPP",
|
||||
"description": "Extensible Messaging and Presence Protocol. Standard historique de messagerie instantanée.",
|
||||
"type": "connexe",
|
||||
"statut": "Actif",
|
||||
"rfc": "RFC 6120"
|
||||
},
|
||||
{
|
||||
"id": "atproto",
|
||||
"name": "AT Protocol",
|
||||
"description": "Protocole d'identité décentralisé utilisé par Bluesky. Écosystème voisin mais distinct du Fédiverse.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://atproto.com/"
|
||||
},
|
||||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"description": "Protocole simple et ouvert pour créer des réseaux sociaux résistants à la censure.",
|
||||
"type": "voisin",
|
||||
"statut": "En développement",
|
||||
"site": "https://nostr.com/"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
- id: activitypub
|
||||
name: ActivityPub
|
||||
description: Protocole de réseautage social décentralisé recommandé par le W3C.
|
||||
Le standard principal du Fédiverse.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
site: https://activitypub.rocks/
|
||||
rfc: W3C Recommendation
|
||||
- id: activitystreams
|
||||
name: ActivityStreams 2.0
|
||||
description: Format de données pour décrire les activités sociales. Base sur laquelle
|
||||
repose ActivityPub.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: W3C Recommendation
|
||||
- id: webfinger
|
||||
name: WebFinger
|
||||
description: Protocole de découverte d'informations sur les ressources identifiées
|
||||
par des URI web.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: RFC 7033
|
||||
- id: httpsignatures
|
||||
name: HTTP Signatures
|
||||
description: Mécanisme de signature des requêtes HTTP pour l'authentification entre
|
||||
instances.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: IETF Draft
|
||||
- id: nodeinfo
|
||||
name: NodeInfo
|
||||
description: Schéma de métadonnées pour décrire les instances fédérées et leurs
|
||||
capacités.
|
||||
type: fediverse
|
||||
statut: Actif
|
||||
rfc: nodeinfo.diaspora.software
|
||||
- id: zot
|
||||
name: Zot / Nomad
|
||||
description: Protocole de communications décentralisées avec prise en charge avancée
|
||||
de la privacy et du nomadisme d'identité.
|
||||
type: connexe
|
||||
statut: Actif
|
||||
site: https://zotlabs.org/
|
||||
- id: diaspora
|
||||
name: Diaspora* Protocol
|
||||
description: Protocole historique du réseau Diaspora*, précurseur du Fédiverse moderne.
|
||||
type: connexe
|
||||
statut: Historique
|
||||
- id: ostatus
|
||||
name: OStatus
|
||||
description: Suite de protocoles (Atom, Salmon, WebSub) utilisée par les premières
|
||||
plateformes fédérées.
|
||||
type: connexe
|
||||
statut: Historique
|
||||
- id: matrix
|
||||
name: Matrix Protocol
|
||||
description: Protocole ouvert pour la communication temps réel (chat, VoIP). Interopère
|
||||
avec ActivityPub.
|
||||
type: connexe
|
||||
statut: Actif
|
||||
site: https://matrix.org/
|
||||
- id: xmpp
|
||||
name: XMPP
|
||||
description: Extensible Messaging and Presence Protocol. Standard historique de
|
||||
messagerie instantanée.
|
||||
type: connexe
|
||||
statut: Actif
|
||||
rfc: RFC 6120
|
||||
- id: atproto
|
||||
name: AT Protocol
|
||||
description: Protocole d'identité décentralisé utilisé par Bluesky. Écosystème voisin
|
||||
mais distinct du Fédiverse.
|
||||
type: voisin
|
||||
statut: En développement
|
||||
site: https://atproto.com/
|
||||
- id: nostr
|
||||
name: Nostr
|
||||
description: Protocole simple et ouvert pour créer des réseaux sociaux résistants
|
||||
à la censure.
|
||||
type: voisin
|
||||
statut: En développement
|
||||
site: https://nostr.com/
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Logo Atlas Fédiverse">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#F72585" />
|
||||
<stop offset="1" stop-color="#4CC9F0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="256" cy="256" r="196" fill="#012A4A" />
|
||||
<circle cx="256" cy="256" r="196" fill="none" stroke="url(#g)" stroke-width="20" />
|
||||
<ellipse cx="256" cy="256" rx="92" ry="196" fill="none" stroke="#4CC9F0" stroke-width="12" opacity="0.75" />
|
||||
<path d="M 74 256 H 438" stroke="#4CC9F0" stroke-width="12" opacity="0.75" />
|
||||
<path d="M 104 168 Q 256 226 408 168" fill="none" stroke="#4CC9F0" stroke-width="10" opacity="0.5" />
|
||||
<path d="M 104 344 Q 256 286 408 344" fill="none" stroke="#4CC9F0" stroke-width="10" opacity="0.5" />
|
||||
<circle cx="256" cy="60" r="20" fill="#F72585" />
|
||||
<circle cx="122" cy="336" r="16" fill="#06D6A0" />
|
||||
<circle cx="390" cy="336" r="16" fill="#F9C74F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 983 B |
@@ -0,0 +1,352 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3508 4961" width="3508px" height="4961px">
|
||||
<rect width="3508" height="4961" fill="#001219"/>
|
||||
<text x="1754" y="120" text-anchor="middle" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="72" font-weight="700">Le Grand Atlas du Fédiverse</text>
|
||||
<text x="1754" y="180" text-anchor="middle" fill="#F72585" font-family="Space Grotesk, sans-serif" font-size="36" font-weight="600">Version 4.0 · 2026</text>
|
||||
<text x="1754" y="230" text-anchor="middle" fill="#A9D6E5" font-family="JetBrains Mono, monospace" font-size="20">90 logiciels · 14 catégories · 12 protocoles</text>
|
||||
<circle cx="1754" cy="2380" r="200" fill="none" stroke="#4CC9F0" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1954.0" cy="2380.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1954.0" cy="2380.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1984.0" y="2380.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mastodon</text>
|
||||
<circle cx="1944.2" cy="2441.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1944.2" cy="2441.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1972.7" y="2451.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">GoToSocial</text>
|
||||
<circle cx="1915.8" cy="2497.6" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1915.8" cy="2497.6" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1940.1" y="2515.2" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Akkoma</text>
|
||||
<circle cx="1871.6" cy="2541.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1871.6" cy="2541.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1889.2" y="2566.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Pleroma</text>
|
||||
<circle cx="1815.8" cy="2570.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1815.8" cy="2570.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1825.1" y="2598.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Misskey</text>
|
||||
<circle cx="1754.0" cy="2580.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1754.0" cy="2580.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1754.0" y="2610.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Sharkey</text>
|
||||
<circle cx="1692.2" cy="2570.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1692.2" cy="2570.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1682.9" y="2598.7" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Friendica</text>
|
||||
<circle cx="1636.4" cy="2541.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1636.4" cy="2541.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1618.8" y="2566.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Hubzilla</text>
|
||||
<circle cx="1592.2" cy="2497.6" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1592.2" cy="2497.6" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1567.9" y="2515.2" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Streams</text>
|
||||
<circle cx="1563.8" cy="2441.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1563.8" cy="2441.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1535.3" y="2451.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Iceshrimp</text>
|
||||
<circle cx="1554.0" cy="2380.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1554.0" cy="2380.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1524.0" y="2380.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">GNU social</text>
|
||||
<circle cx="1563.8" cy="2318.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1563.8" cy="2318.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1535.3" y="2308.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Bonfire</text>
|
||||
<circle cx="1592.2" cy="2262.4" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1592.2" cy="2262.4" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1567.9" y="2244.8" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Diaspora*</text>
|
||||
<circle cx="1636.4" cy="2218.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1636.4" cy="2218.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1618.8" y="2193.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mitra</text>
|
||||
<circle cx="1692.2" cy="2189.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1692.2" cy="2189.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1682.9" y="2161.3" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Socialhome</text>
|
||||
<circle cx="1754.0" cy="2180.0" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1754.0" cy="2180.0" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1754.0" y="2150.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Epicyon</text>
|
||||
<circle cx="1815.8" cy="2189.8" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1815.8" cy="2189.8" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1825.1" y="2161.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Takahē</text>
|
||||
<circle cx="1871.6" cy="2218.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1871.6" cy="2218.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1889.2" y="2193.9" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Smithereen</text>
|
||||
<circle cx="1915.8" cy="2262.4" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1915.8" cy="2262.4" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1940.1" y="2244.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Hometown</text>
|
||||
<circle cx="1944.2" cy="2318.2" r="6" fill="#4CC9F0" opacity="0.9"/>
|
||||
<circle cx="1944.2" cy="2318.2" r="10" fill="none" stroke="#4CC9F0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1972.7" y="2308.9" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">GlitchSoc</text>
|
||||
<circle cx="1754" cy="2380" r="265" fill="none" stroke="#F7931A" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1994.2" cy="2492.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1994.2" cy="2492.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2021.4" y="2504.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nostr Protocol</text>
|
||||
<circle cx="1927.9" cy="2580.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1927.9" cy="2580.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1947.5" y="2602.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Damus</text>
|
||||
<circle cx="1831.5" cy="2633.4" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1831.5" cy="2633.4" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1840.2" y="2662.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Amethyst</text>
|
||||
<circle cx="1721.7" cy="2643.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1721.7" cy="2643.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1718.0" y="2672.8" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Iris</text>
|
||||
<circle cx="1617.5" cy="2607.1" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1617.5" cy="2607.1" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1602.1" y="2632.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Primal</text>
|
||||
<circle cx="1536.9" cy="2532.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1536.9" cy="2532.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1512.4" y="2549.2" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Habla.news</text>
|
||||
<circle cx="1493.9" cy="2430.6" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1493.9" cy="2430.6" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1464.4" y="2436.3" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nostr.band</text>
|
||||
<circle cx="1495.8" cy="2320.4" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1495.8" cy="2320.4" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1466.6" y="2313.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Snort</text>
|
||||
<circle cx="1542.4" cy="2220.5" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1542.4" cy="2220.5" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1518.4" y="2202.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Coracle</text>
|
||||
<circle cx="1625.5" cy="2148.2" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1625.5" cy="2148.2" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1611.0" y="2122.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nostrudel</text>
|
||||
<circle cx="1730.9" cy="2116.0" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1730.9" cy="2116.0" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1728.3" y="2086.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Lume</text>
|
||||
<circle cx="1840.3" cy="2129.4" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1840.3" cy="2129.4" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1850.0" y="2101.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gossip</text>
|
||||
<circle cx="1934.7" cy="2186.2" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1934.7" cy="2186.2" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1955.2" y="2164.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Current</text>
|
||||
<circle cx="1997.9" cy="2276.5" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="1997.9" cy="2276.5" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2025.5" y="2264.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Plebstr</text>
|
||||
<circle cx="2019.0" cy="2384.6" r="6" fill="#F7931A" opacity="0.9"/>
|
||||
<circle cx="2019.0" cy="2384.6" r="10" fill="none" stroke="#F7931A" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2049.0" y="2385.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ZapStream</text>
|
||||
<circle cx="1754" cy="2380" r="330" fill="none" stroke="#06D6A0" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1966.1" cy="2632.8" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1966.1" cy="2632.8" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1985.4" y="2655.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">SimpleX Chat</text>
|
||||
<circle cx="1824.3" cy="2702.4" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1824.3" cy="2702.4" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1830.7" y="2731.7" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Conversations</text>
|
||||
<circle cx="1666.5" cy="2698.2" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1666.5" cy="2698.2" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1658.5" y="2727.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Dino</text>
|
||||
<circle cx="1528.6" cy="2621.0" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1528.6" cy="2621.0" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1508.1" y="2643.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gajim</text>
|
||||
<circle cx="1442.4" cy="2488.7" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1442.4" cy="2488.7" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1414.1" y="2498.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Monal</text>
|
||||
<circle cx="1427.6" cy="2331.4" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1427.6" cy="2331.4" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1397.9" y="2327.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Snikket</text>
|
||||
<circle cx="1487.5" cy="2185.3" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1487.5" cy="2185.3" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1463.3" y="2167.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Movim</text>
|
||||
<circle cx="1608.5" cy="2083.8" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1608.5" cy="2083.8" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1595.3" y="2056.9" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Briar</text>
|
||||
<circle cx="1762.9" cy="2050.1" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1762.9" cy="2050.1" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1763.7" y="2020.1" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Jami</text>
|
||||
<circle cx="1915.1" cy="2092.0" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="1915.1" cy="2092.0" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1929.8" y="2065.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Prosody</text>
|
||||
<circle cx="2030.5" cy="2199.9" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="2030.5" cy="2199.9" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2055.7" y="2183.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ejabberd</text>
|
||||
<circle cx="2082.5" cy="2349.0" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="2082.5" cy="2349.0" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2112.4" y="2346.2" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Openfire</text>
|
||||
<circle cx="2059.3" cy="2505.3" r="6" fill="#06D6A0" opacity="0.9"/>
|
||||
<circle cx="2059.3" cy="2505.3" r="10" fill="none" stroke="#06D6A0" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2087.1" y="2516.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">jabber</text>
|
||||
<circle cx="1754" cy="2380" r="395" fill="none" stroke="#2EC4B6" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1856.2" cy="2761.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1856.2" cy="2761.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1864.0" y="2790.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Nextcloud</text>
|
||||
<circle cx="1651.8" cy="2761.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1651.8" cy="2761.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1644.0" y="2790.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ownCloud</text>
|
||||
<circle cx="1474.7" cy="2659.3" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1474.7" cy="2659.3" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1453.5" y="2680.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Element</text>
|
||||
<circle cx="1372.5" cy="2482.2" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1372.5" cy="2482.2" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1343.5" y="2490.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">CryptPad</text>
|
||||
<circle cx="1372.5" cy="2277.8" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1372.5" cy="2277.8" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1343.5" y="2270.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">HedgeDoc</text>
|
||||
<circle cx="1474.7" cy="2100.7" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1474.7" cy="2100.7" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1453.5" y="2079.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Immich</text>
|
||||
<circle cx="1651.8" cy="1998.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1651.8" cy="1998.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1644.0" y="1969.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">PhotoPrism</text>
|
||||
<circle cx="1856.2" cy="1998.5" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="1856.2" cy="1998.5" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1864.0" y="1969.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Synapse</text>
|
||||
<circle cx="2033.3" cy="2100.7" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2033.3" cy="2100.7" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2054.5" y="2079.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Dendrite</text>
|
||||
<circle cx="2135.5" cy="2277.8" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2135.5" cy="2277.8" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2164.5" y="2270.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Conduit</text>
|
||||
<circle cx="2135.5" cy="2482.2" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2135.5" cy="2482.2" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2164.5" y="2490.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Cinny</text>
|
||||
<circle cx="2033.3" cy="2659.3" r="6" fill="#2EC4B6" opacity="0.9"/>
|
||||
<circle cx="2033.3" cy="2659.3" r="10" fill="none" stroke="#2EC4B6" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2054.5" y="2680.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">FluffyChat</text>
|
||||
<circle cx="1754" cy="2380" r="460" fill="none" stroke="#7209B7" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1674.1" cy="2833.0" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="1674.1" cy="2833.0" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1668.9" y="2862.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Forgejo</text>
|
||||
<circle cx="1298.5" cy="2444.0" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="1298.5" cy="2444.0" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1268.8" y="2448.2" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gitea</text>
|
||||
<circle cx="1552.3" cy="1966.6" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="1552.3" cy="1966.6" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1539.2" y="1939.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Fedify</text>
|
||||
<circle cx="2084.9" cy="2060.5" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="2084.9" cy="2060.5" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2106.5" y="2039.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">ActivityPub Relay</text>
|
||||
<circle cx="2160.2" cy="2596.0" r="6" fill="#7209B7" opacity="0.9"/>
|
||||
<circle cx="2160.2" cy="2596.0" r="10" fill="none" stroke="#7209B7" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2186.6" y="2610.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">FediBridge</text>
|
||||
<circle cx="1754" cy="2380" r="525" fill="none" stroke="#3A0CA3" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1452.9" cy="2810.1" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1452.9" cy="2810.1" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1435.7" y="2834.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Lemmy</text>
|
||||
<circle cx="1231.0" cy="2334.2" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1231.0" cy="2334.2" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1201.1" y="2331.6" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mbin</text>
|
||||
<circle cx="1532.1" cy="1904.2" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1532.1" cy="1904.2" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1519.4" y="1877.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">NodeBB</text>
|
||||
<circle cx="2055.1" cy="1949.9" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="2055.1" cy="1949.9" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2072.3" y="1925.4" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Lotide</text>
|
||||
<circle cx="2277.0" cy="2425.8" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="2277.0" cy="2425.8" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2306.9" y="2428.4" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Sublinks</text>
|
||||
<circle cx="1975.9" cy="2855.8" r="6" fill="#3A0CA3" opacity="0.9"/>
|
||||
<circle cx="1975.9" cy="2855.8" r="10" fill="none" stroke="#3A0CA3" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1988.6" y="2883.0" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">kbin</text>
|
||||
<circle cx="1754" cy="2380" r="590" fill="none" stroke="#F9C74F" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1243.0" cy="2675.0" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1243.0" cy="2675.0" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1217.1" y="2690.0" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Ghost</text>
|
||||
<circle cx="1315.5" cy="1985.2" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1315.5" cy="1985.2" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1293.3" y="1965.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">WriteFreely</text>
|
||||
<circle cx="1994.0" cy="1841.0" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1994.0" cy="1841.0" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2006.2" y="1813.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Plume</text>
|
||||
<circle cx="2340.8" cy="2441.7" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="2340.8" cy="2441.7" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2370.6" y="2444.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">WordPress + AP</text>
|
||||
<circle cx="1876.7" cy="2957.1" r="6" fill="#F9C74F" opacity="0.9"/>
|
||||
<circle cx="1876.7" cy="2957.1" r="10" fill="none" stroke="#F9C74F" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1882.9" y="2986.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Drupal + AP</text>
|
||||
<circle cx="1754" cy="2380" r="655" fill="none" stroke="#F72585" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1101.5" cy="2437.1" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="1101.5" cy="2437.1" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1071.6" y="2439.7" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">PeerTube</text>
|
||||
<circle cx="1498.1" cy="1777.1" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="1498.1" cy="1777.1" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1486.3" y="1749.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Owncast</text>
|
||||
<circle cx="2248.3" cy="1950.3" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="2248.3" cy="1950.3" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2271.0" y="1930.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Tube (Framasoft)</text>
|
||||
<circle cx="2315.4" cy="2717.3" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="2315.4" cy="2717.3" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2341.2" y="2732.8" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Loops</text>
|
||||
<circle cx="1606.7" cy="3018.2" r="6" fill="#F72585" opacity="0.9"/>
|
||||
<circle cx="1606.7" cy="3018.2" r="10" fill="none" stroke="#F72585" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1599.9" y="3047.4" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Sepia Search</text>
|
||||
<circle cx="1754" cy="2380" r="720" fill="none" stroke="#B5179E" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1077.4" cy="2133.7" r="6" fill="#B5179E" opacity="0.9"/>
|
||||
<circle cx="1077.4" cy="2133.7" r="10" fill="none" stroke="#B5179E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1049.2" y="2123.5" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Castopod</text>
|
||||
<circle cx="2430.6" cy="2626.3" r="6" fill="#B5179E" opacity="0.9"/>
|
||||
<circle cx="2430.6" cy="2626.3" r="10" fill="none" stroke="#B5179E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2458.8" y="2636.5" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Funkwhale</text>
|
||||
<circle cx="1754" cy="2380" r="785" fill="none" stroke="#F8961E" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1198.9" cy="1824.9" r="6" fill="#F8961E" opacity="0.9"/>
|
||||
<circle cx="1198.9" cy="1824.9" r="10" fill="none" stroke="#F8961E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1177.7" y="1803.7" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">PixelFed</text>
|
||||
<circle cx="2309.1" cy="2935.1" r="6" fill="#F8961E" opacity="0.9"/>
|
||||
<circle cx="2309.1" cy="2935.1" r="10" fill="none" stroke="#F8961E" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2330.3" y="2956.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Vernissage</text>
|
||||
<circle cx="1754" cy="2380" r="850" fill="none" stroke="#4361EE" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1463.3" cy="1581.3" r="6" fill="#4361EE" opacity="0.9"/>
|
||||
<circle cx="1463.3" cy="1581.3" r="10" fill="none" stroke="#4361EE" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1453.0" y="1553.1" text-anchor="end" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Mobilizon</text>
|
||||
<circle cx="2044.7" cy="3178.7" r="6" fill="#4361EE" opacity="0.9"/>
|
||||
<circle cx="2044.7" cy="3178.7" r="10" fill="none" stroke="#4361EE" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2055.0" y="3206.9" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Gancio</text>
|
||||
<circle cx="1754" cy="2380" r="915" fill="none" stroke="#E71D36" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="1833.7" cy="1468.5" r="6" fill="#E71D36" opacity="0.9"/>
|
||||
<circle cx="1833.7" cy="1468.5" r="10" fill="none" stroke="#E71D36" stroke-width="1" opacity="0.3"/>
|
||||
<text x="1836.4" y="1438.6" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">microblog.pub</text>
|
||||
<circle cx="1754" cy="2380" r="980" fill="none" stroke="#E71D36" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="2244.0" cy="1531.3" r="6" fill="#E71D36" opacity="0.9"/>
|
||||
<circle cx="2244.0" cy="1531.3" r="10" fill="none" stroke="#E71D36" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2259.0" y="1505.3" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">BookWyrm</text>
|
||||
<circle cx="1754" cy="2380" r="1045" fill="none" stroke="#662E9B" stroke-width="1.5" stroke-dasharray="4,6" opacity="0.3"/>
|
||||
<circle cx="2610.0" cy="1780.6" r="6" fill="#662E9B" opacity="0.9"/>
|
||||
<circle cx="2610.0" cy="1780.6" r="10" fill="none" stroke="#662E9B" stroke-width="1" opacity="0.3"/>
|
||||
<text x="2634.6" y="1763.4" text-anchor="start" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="14" opacity="0.85">Wikidata</text>
|
||||
<circle cx="1754" cy="2380" r="170" fill="none" stroke="#F72585" stroke-width="3"/>
|
||||
<circle cx="1754" cy="2380" r="150" fill="none" stroke="#4CC9F0" stroke-width="1.5" opacity="0.5"/>
|
||||
<text x="1754" y="2365" text-anchor="middle" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="28" font-weight="700">LE FÉDIVERSE</text>
|
||||
<text x="1754" y="2395" text-anchor="middle" fill="#F72585" font-family="JetBrains Mono, monospace" font-size="14">ActivityPub · 2026</text>
|
||||
<text x="1754" y="2420" text-anchor="middle" fill="#A9D6E5" font-family="Inter, sans-serif" font-size="12">90 logiciels fédérés</text>
|
||||
<line x1="200" y1="4411" x2="3308" y2="4411" stroke="#FFFFFF" stroke-width="1" opacity="0.2"/>
|
||||
<text x="1754" y="4461" text-anchor="middle" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="32" font-weight="600">Protocoles</text>
|
||||
<rect x="324" y="4501" width="220" height="50" rx="8" fill="#F7258515" stroke="#F7258540" stroke-width="1"/>
|
||||
<text x="336" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">ActivityPub</text>
|
||||
<text x="336" y="4541" fill="#F72585" font-family="JetBrains Mono, monospace" font-size="10">W3C Recommendation</text>
|
||||
<rect x="564" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="576" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">WebFinger</text>
|
||||
<text x="576" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">RFC 7033</text>
|
||||
<rect x="804" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="816" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">HTTP Signatures</text>
|
||||
<text x="816" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">IETF Draft</text>
|
||||
<rect x="1044" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="1056" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">NodeInfo</text>
|
||||
<text x="1056" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">Active</text>
|
||||
<rect x="1284" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="1296" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">ActivityStreams</text>
|
||||
<text x="1296" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">W3C</text>
|
||||
<rect x="1524" y="4501" width="220" height="50" rx="8" fill="#F9C74F15" stroke="#F9C74F40" stroke-width="1"/>
|
||||
<text x="1536" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Zot/Nomad</text>
|
||||
<text x="1536" y="4541" fill="#F9C74F" font-family="JetBrains Mono, monospace" font-size="10">Hubzilla/Streams</text>
|
||||
<rect x="1764" y="4501" width="220" height="50" rx="8" fill="#6C757D15" stroke="#6C757D40" stroke-width="1"/>
|
||||
<text x="1776" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Diaspora*</text>
|
||||
<text x="1776" y="4541" fill="#6C757D" font-family="JetBrains Mono, monospace" font-size="10">Historical</text>
|
||||
<rect x="2004" y="4501" width="220" height="50" rx="8" fill="#6C757D15" stroke="#6C757D40" stroke-width="1"/>
|
||||
<text x="2016" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">OStatus</text>
|
||||
<text x="2016" y="4541" fill="#6C757D" font-family="JetBrains Mono, monospace" font-size="10">Historical</text>
|
||||
<rect x="2244" y="4501" width="220" height="50" rx="8" fill="#4361EE15" stroke="#4361EE40" stroke-width="1"/>
|
||||
<text x="2256" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Matrix</text>
|
||||
<text x="2256" y="4541" fill="#4361EE" font-family="JetBrains Mono, monospace" font-size="10">Real-time</text>
|
||||
<rect x="2484" y="4501" width="220" height="50" rx="8" fill="#06D6A015" stroke="#06D6A040" stroke-width="1"/>
|
||||
<text x="2496" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">XMPP</text>
|
||||
<text x="2496" y="4541" fill="#06D6A0" font-family="JetBrains Mono, monospace" font-size="10">RFC 6120</text>
|
||||
<rect x="2724" y="4501" width="220" height="50" rx="8" fill="#F7931A15" stroke="#F7931A40" stroke-width="1"/>
|
||||
<text x="2736" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">Nostr</text>
|
||||
<text x="2736" y="4541" fill="#F7931A" font-family="JetBrains Mono, monospace" font-size="10">Neighbor</text>
|
||||
<rect x="2964" y="4501" width="220" height="50" rx="8" fill="#4CC9F015" stroke="#4CC9F040" stroke-width="1"/>
|
||||
<text x="2976" y="4523" fill="#FFFFFF" font-family="Space Grotesk, sans-serif" font-size="14" font-weight="600">AT Protocol</text>
|
||||
<text x="2976" y="4541" fill="#4CC9F0" font-family="JetBrains Mono, monospace" font-size="10">Bluesky</text>
|
||||
<text x="1754" y="4661" text-anchor="middle" fill="#A9D6E5" font-family="Space Grotesk, sans-serif" font-size="20">Légende</text>
|
||||
<circle cx="954" cy="4691" r="8" fill="#4CC9F0"/>
|
||||
<text x="970" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Fédéré ActivityPub</text>
|
||||
<circle cx="1154" cy="4691" r="8" fill="#F7931A"/>
|
||||
<text x="1170" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Nostr</text>
|
||||
<circle cx="1354" cy="4691" r="8" fill="#06D6A0"/>
|
||||
<text x="1370" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Messagerie</text>
|
||||
<circle cx="1554" cy="4691" r="8" fill="#F72585"/>
|
||||
<text x="1570" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Multimédia</text>
|
||||
<circle cx="1754" cy="4691" r="8" fill="#2EC4B6"/>
|
||||
<text x="1770" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Collaboration</text>
|
||||
<circle cx="1954" cy="4691" r="8" fill="#7209B7"/>
|
||||
<text x="1970" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Développement</text>
|
||||
<circle cx="2154" cy="4691" r="8" fill="#F9C74F"/>
|
||||
<text x="2170" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Contenu</text>
|
||||
<circle cx="2354" cy="4691" r="8" fill="#6C757D"/>
|
||||
<text x="2370" y="4696" fill="#FFFFFF" font-family="Inter, sans-serif" font-size="13">Historique</text>
|
||||
<text x="1754" y="4841" text-anchor="middle" fill="#A9D6E5" font-family="JetBrains Mono, monospace" font-size="14">axbom.com/fediverse · CC BY-SA 4.0 · Per Axbom (original v3.0 2023) · Atlas v4.0 2026</text>
|
||||
<text x="1754" y="4871" text-anchor="middle" fill="#F72585" font-family="Inter, sans-serif" font-size="12">legrandatlasdufediverse.org · Données maintenues par la communauté</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Atlas Fediverse",
|
||||
"short_name": "Atlas",
|
||||
"description": "Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé.",
|
||||
"lang": "fr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#001219",
|
||||
"theme_color": "#001219",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined,
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
// Déployé à la racine de https://syel.o-k-i.net (YunoHost / nginx) —
|
||||
// pas de paths.base. La CSP est gérée par build/nginx-csp.conf (snippet
|
||||
// généré au build, à inclure dans la config nginx de l'app).
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
}
|
||||
});
|
||||