chore: état initial avant refonte (atlas SvelteKit v4.0.0)

Snapshot de l'existant avant application du playbook OKI :
background shader Three.js, carte graphe 3D, timeline statique.
This commit is contained in:
sucupira
2026-07-21 13:10:12 -04:00
commit 93c577c5d3
80 changed files with 54745 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
/* app.css — point d'entrée des styles, ordre de cascade explicite */
@layer reset, tokens, base, layouts, components, utilities;
@import './styles/reset.css' layer(reset);
@import './styles/tokens.css' layer(tokens);
@import './styles/themes/default.css' layer(tokens);
@import './styles/base.css' layer(base);
@import './styles/layouts.css' layer(layouts);
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Le Grand Atlas du Fédiverse — 2026</title>
<meta
name="description"
content="Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé."
/>
<meta name="theme-color" content="#001219" />
<meta name="color-scheme" content="light dark" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/icons/logo.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
<link
rel="preload"
href="%sveltekit.assets%/fonts/space-grotesk-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="%sveltekit.assets%/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<style>
/* 2 fichiers WOFF2 variables, auto-hébergés, font-display: swap.
Dans app.html pour bénéficier de %sveltekit.assets% (base /fediverse). */
@font-face {
font-family: 'Space Grotesk';
src: url('%sveltekit.assets%/fonts/space-grotesk-var.woff2') format('woff2');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('%sveltekit.assets%/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<div
id="boot-warning"
hidden
style="position: fixed; inset-inline: 1rem; inset-block-end: 1rem; z-index: 9999; padding: 1rem 1.25rem; border: 2px solid #f72585; border-radius: 1rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; font-size: 0.9375rem; line-height: 1.5; box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);"
>
<strong>L'application n'a pas pu démarrer.</strong>
Si vous lisez ce fichier en local (<code>file://</code>), servez le dossier
<code>build/</code> via HTTP (par exemple <code>npx serve build</code>) : les navigateurs
bloquent les modules JavaScript sur <code>file://</code>. Sinon, videz les données du site
dans votre navigateur puis rechargez la page.
</div>
<noscript>
<div
style="padding: 1rem 1.25rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; text-align: center;"
>
JavaScript est désactivé : le contenu de l'Atlas reste lisible, mais la carte 3D et les
fenêtres de détails sont indisponibles.
</div>
</noscript>
<script src="%sveltekit.assets%/boot-check.js" defer></script>
</body>
</html>
@@ -0,0 +1,239 @@
<script lang="ts">
import { base } from '$app/paths';
import { getLocale, LOCALES, setLocale, t } from '$lib/i18n/i18n.svelte';
import type { Locale } from '$lib/i18n/i18n.svelte';
const projectLinks = [
{ href: `${base}/#map`, labelKey: 'footer.about' },
{ href: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', labelKey: 'footer.license' },
{ href: 'https://github.com/', labelKey: 'footer.source' },
{ href: `${base}/data/fediverse.json`, labelKey: 'footer.api' }
] as const;
const resourceLinks = [
{ href: 'https://activitypub.rocks/', label: 'ActivityPub.rocks' },
{ href: 'https://fedidb.org/', label: 'FediDB' },
{ href: 'https://jointhefediverse.net/', label: 'Join the Fediverse' },
{ href: 'https://fediverse.info/', label: 'Fediverse.info' }
];
const communityLinks = [
{ href: 'https://joinmastodon.org/fr', label: 'Mastodon' },
{ href: 'https://join-lemmy.org/', label: 'Lemmy' },
{ href: 'https://joinpeertube.org/', label: 'PeerTube' },
{ href: 'https://pixelfed.org/', label: 'PixelFed' }
];
const downloads = [
{ href: `${base}/data/fediverse.json`, label: 'JSON' },
{ href: `${base}/data/fediverse.yaml`, label: 'YAML' },
{ href: `${base}/infographie-fediverse-2026.svg`, label: 'Infographie SVG' },
{ href: `${base}/infographie-fediverse-2026.pdf`, label: 'Infographie PDF' }
];
const currentLocale = $derived(getLocale());
</script>
<footer>
<div class="center stack footer-stack">
<div class="footer-grid">
<div class="stack brand-col">
<a href="{base}/" class="brand" aria-label={t('header.home')}>
<img src="{base}/icons/logo.svg" alt="" width="40" height="40" />
<span class="brand-name">Le Grand Atlas du Fédiverse</span>
</a>
<p class="muted">{t('footer.baseline')}</p>
<p class="muted small">Version 4.0 · 2026 · {t('footer.madeIn')}</p>
<div class="locale-switcher" role="group" aria-label={t('footer.localeLabel')}>
{#each LOCALES as locale (locale.id)}
<button
type="button"
class="locale-btn"
aria-pressed={currentLocale === locale.id}
onclick={() => setLocale(locale.id as Locale)}
>
{locale.label}
</button>
{/each}
</div>
</div>
<nav class="stack link-col" aria-label={t('footer.project')}>
<h3>{t('footer.project')}</h3>
<ul class="stack" role="list">
{#each projectLinks as link (link.href)}
<li>
<a href={link.href} {...link.href.startsWith('http') ? { rel: 'external' } : {}}>
{t(link.labelKey)}
</a>
</li>
{/each}
</ul>
</nav>
<nav class="stack link-col" aria-label={t('footer.resources')}>
<h3>{t('footer.resources')}</h3>
<ul class="stack" role="list">
{#each resourceLinks as link (link.href)}
<li><a href={link.href} rel="external">{link.label}</a></li>
{/each}
</ul>
</nav>
<nav class="stack link-col" aria-label={t('footer.community')}>
<h3>{t('footer.community')}</h3>
<ul class="stack" role="list">
{#each communityLinks as link (link.href)}
<li><a href={link.href} rel="external">{link.label}</a></li>
{/each}
</ul>
</nav>
</div>
<div class="downloads cluster" aria-label="Téléchargements">
{#each downloads as dl (dl.href)}
<a href={dl.href} class="download-chip" download>{dl.label}</a>
{/each}
</div>
<div class="bottom-bar stack">
<p class="small muted">{t('footer.rights')}</p>
<p class="small muted">{t('footer.credits')}</p>
</div>
</div>
</footer>
<style>
footer {
background: var(--abyss);
border-block-start: 1px solid var(--border);
padding-block: var(--space-6) var(--space-4);
}
.footer-stack {
--stack-gap: var(--space-5);
}
.footer-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: 1fr;
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--ink);
text-decoration: none;
}
.brand-name {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--text-2);
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.brand-col {
--stack-gap: var(--space-3);
max-inline-size: 34rem;
}
.link-col {
--stack-gap: var(--space-2);
}
.link-col ul {
--stack-gap: var(--space-1);
}
h3 {
font-size: var(--text-1);
color: var(--ink);
}
.link-col a {
display: inline-flex;
align-items: center;
min-block-size: 2rem;
color: var(--ink-soft);
text-decoration: none;
}
.link-col a:hover {
color: var(--accent-2-text);
text-decoration: underline;
}
.muted {
color: var(--muted);
}
.locale-switcher {
display: flex;
gap: var(--space-2);
margin-block-start: var(--space-2);
}
.locale-btn {
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
color: var(--ink-soft);
font-weight: 500;
transition:
color var(--duration-1) var(--ease-out),
border-color var(--duration-1) var(--ease-out);
}
.locale-btn:hover {
color: var(--ink);
border-color: var(--border-strong);
}
.locale-btn[aria-pressed='true'] {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.downloads {
--cluster-gap: var(--space-2);
}
.download-chip {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-pill);
color: var(--ink-soft);
text-decoration: none;
font-family: var(--font-mono);
font-size: var(--text-small);
}
.download-chip:hover {
color: var(--accent-2-text);
border-color: var(--accent-2-text);
}
.bottom-bar {
--stack-gap: var(--space-1);
border-block-start: 1px solid var(--border);
padding-block-start: var(--space-3);
}
@media (min-width: 48rem) {
.footer-grid {
grid-template-columns: 2fr 1fr 1fr 1fr;
}
}
</style>
@@ -0,0 +1,320 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { base } from '$app/paths';
import { t } from '$lib/i18n/i18n.svelte';
import { canInstall, promptInstall } from '$lib/pwa.svelte';
let scrolled = $state(false);
let hidden = $state(false);
let menuOpen = $state(false);
let menuButton = $state<HTMLButtonElement | null>(null);
let mobileNav = $state<HTMLElement | null>(null);
const anchors = [
{ href: `${base}/#map`, key: 'nav.map' },
{ href: `${base}/#protocols`, key: 'nav.protocols' },
{ href: `${base}/#timeline`, key: 'nav.timeline' },
{ href: `${base}/#catalog`, key: 'nav.catalog' }
] as const;
// Slash final : évite la redirection 301 d'Apache (DirectorySlash)
const routes = [
{ href: `${base}/nostr/`, key: 'nav.nostr', accent: 'var(--accent-3)' },
{ href: `${base}/simplex/`, key: 'nav.simplex', accent: 'var(--accent-4)' }
] as const;
const pathname: string = $derived(page.url.pathname);
onMount(() => {
let lastY = 0;
const onScroll = () => {
const y = window.scrollY;
scrolled = y > 80;
// Auto-masquage : disparaît en défilant vers le bas (lecture),
// réapparaît dès que l'utilisateur remonte. Jamais masqué en haut
// de page ni quand le menu mobile est ouvert.
if (y > 160 && y > lastY) {
hidden = true;
menuOpen = false;
} else if (y < lastY || y <= 160) {
hidden = false;
}
lastY = y;
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && menuOpen) {
menuOpen = false;
menuButton?.focus();
}
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('keydown', onKeydown);
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('keydown', onKeydown);
};
});
// Ferme le menu mobile à chaque navigation
$effect(() => {
pathname;
menuOpen = false;
});
$effect(() => {
if (menuOpen) {
mobileNav?.querySelector<HTMLElement>('a, button')?.focus();
}
});
</script>
<header class:scrolled class:hidden>
<div class="header-inner center">
<a href="{base}/" class="brand" aria-label={t('header.home')}>
<img src="{base}/icons/logo.svg" alt="" width="34" height="34" />
<span class="wordmark">Atlas</span>
</a>
<nav class="desktop-nav" aria-label={t('nav.main')}>
<ul class="cluster" role="list">
{#each anchors as link (link.href)}
<li><a href={link.href} class="nav-link">{t(link.key)}</a></li>
{/each}
{#each routes as route (route.href)}
<li>
<a
href={route.href}
class="nav-link route-link"
style:--link-accent={route.accent}
aria-current={pathname === route.href ? 'page' : undefined}
>
{ t(route.key)}
</a>
</li>
{/each}
</ul>
</nav>
<div class="actions cluster">
{#if canInstall()}
<button type="button" class="install-btn" onclick={() => promptInstall()}>
{t('header.install')}
</button>
{/if}
<button
type="button"
class="menu-btn"
bind:this={menuButton}
aria-expanded={menuOpen}
aria-controls="mobile-nav"
aria-label={menuOpen ? t('nav.closeMenu') : t('nav.openMenu')}
onclick={() => (menuOpen = !menuOpen)}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{#if menuOpen}
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{:else}
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{/if}
</svg>
</button>
</div>
</div>
{#if menuOpen}
<nav
id="mobile-nav"
class="mobile-nav"
aria-label={t('nav.main')}
bind:this={mobileNav}
>
<ul class="stack" role="list">
{#each anchors as link (link.href)}
<li><a href={link.href} class="mobile-link">{t(link.key)}</a></li>
{/each}
{#each routes as route (route.href)}
<li>
<a
href={route.href}
class="mobile-link route-link"
style:--link-accent={route.accent}
aria-current={pathname === route.href ? 'page' : undefined}
>
{t(route.key)}
</a>
</li>
{/each}
{#if canInstall()}
<li>
<button type="button" class="mobile-link install-mobile" onclick={() => promptInstall()}>
{t('header.install')}
</button>
</li>
{/if}
</ul>
</nav>
{/if}
</header>
<style>
header {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
z-index: 50;
block-size: var(--header-h);
display: flex;
flex-direction: column;
justify-content: center;
transition:
transform var(--duration-2) var(--ease-out),
background-color var(--duration-2) var(--ease-out),
border-color var(--duration-2) var(--ease-out);
border-block-end: 1px solid transparent;
}
header.hidden {
transform: translateY(-100%);
}
header.scrolled {
background: var(--glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-block-end-color: var(--glass-border);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
inline-size: 100%;
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-block-size: var(--tap-target);
text-decoration: none;
color: var(--ink);
}
.wordmark {
font-family: var(--font-display);
font-size: var(--text-2);
font-weight: 700;
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.desktop-nav {
display: none;
}
.nav-link {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border-radius: var(--radius-1);
color: var(--ink-soft);
text-decoration: none;
font-weight: 500;
transition: color var(--duration-1) var(--ease-out);
}
.nav-link:hover {
color: var(--ink);
}
.route-link {
border: 1px solid var(--border);
}
.route-link:hover,
.route-link[aria-current='page'] {
color: var(--link-accent);
border-color: var(--link-accent);
}
.actions {
gap: var(--space-2);
}
.install-btn {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
box-shadow: var(--glow-accent);
}
.menu-btn {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--tap-target);
block-size: var(--tap-target);
border-radius: var(--radius-1);
color: var(--ink);
}
.mobile-nav {
position: fixed;
inset-block-start: var(--header-h);
inset-inline: 0;
background: var(--glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-block-end: 1px solid var(--glass-border);
padding: var(--space-3);
}
.mobile-nav ul {
--stack-gap: var(--space-2);
}
.mobile-link {
display: flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border-radius: var(--radius-1);
color: var(--ink);
text-decoration: none;
font-size: var(--text-1);
font-weight: 500;
inline-size: 100%;
}
.mobile-link:hover {
background: var(--surface-2);
}
.install-mobile {
background: var(--accent);
color: var(--on-accent);
justify-content: center;
font-weight: 600;
}
@media (min-width: 48rem) {
.desktop-nav {
display: block;
}
.menu-btn {
display: none;
}
}
</style>
@@ -0,0 +1,185 @@
<script lang="ts">
/* HeroSection — plein viewport, shader océan en fond, titre + CTA. */
import { stats } from '$lib/data';
import HeroShader from './HeroShader.svelte';
</script>
<section id="hero" aria-labelledby="hero-title">
<HeroShader />
<div class="veil" aria-hidden="true"></div>
<div class="content center stack">
<h1 id="hero-title">
Le réseau social,<br />
<span class="gradient">sans les chaînes</span>
</h1>
<p class="lede">
Explorez le Fédiverse : une cartographie visuelle des
<strong>{stats.totalSoftwares} logiciels</strong>
qui redéfinissent la communication en ligne.
</p>
<a href="#map" class="cta">
Plonger dans la carte
<svg
class="chevron"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</a>
</div>
<div class="scroll-indicator" aria-hidden="true">
<span class="scroll-label">Scroll</span>
<span class="scroll-line"></span>
</div>
</section>
<style>
section {
/* Contexte sombre local : light-dark() bascule en mode sombre ici,
quel que soit le thème de la page. */
color-scheme: dark;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-block-size: 100svh;
overflow: hidden;
background: var(--abyss);
color: var(--on-accent);
scroll-margin-top: var(--header-h);
}
/* Voile de lisibilité : garantit le contraste du texte sur le shader */
.veil {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
color-mix(in oklab, var(--abyss) 45%, transparent),
color-mix(in oklab, var(--abyss) 25%, transparent) 45%,
var(--abyss)
);
pointer-events: none;
}
.content {
position: relative;
z-index: 1;
--stack-gap: var(--space-4);
align-items: center;
text-align: center;
padding-block: var(--space-6);
}
h1 {
font-size: clamp(var(--text-4), 4vw + 1.5rem, 4.5rem);
line-height: 1.1;
}
.gradient {
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.lede {
max-inline-size: var(--measure);
color: var(--ink-soft);
font-size: var(--text-2);
line-height: 1.6;
}
.lede strong {
color: var(--on-accent);
}
.cta {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-block-size: var(--tap-target);
padding-inline: var(--space-4);
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: var(--text-small);
text-decoration: none;
box-shadow: var(--glow-accent);
transition: transform var(--duration-2) var(--ease-out);
}
.cta:hover {
transform: scale(1.05);
}
.chevron {
animation: bounce 1.6s var(--ease-out) infinite;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(0.25rem);
}
}
.scroll-indicator {
position: absolute;
inset-block-end: var(--space-4);
inset-inline-start: 50%;
translate: -50% 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
opacity: 0.6;
}
.scroll-label {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--ink-soft);
}
.scroll-line {
inline-size: 1px;
block-size: var(--space-5);
background: linear-gradient(180deg, var(--ink-soft), transparent);
}
@media (prefers-reduced-motion: reduce) {
.chevron {
animation: none;
}
.cta:hover {
transform: none;
}
.scroll-indicator {
display: none;
}
}
</style>
@@ -0,0 +1,201 @@
<script lang="ts">
/* HeroShader — océan WebGL (Three.js, chargé paresseusement côté client).
Remplit son parent en absolu ; purement décoratif (aria-hidden). */
import { onMount } from 'svelte';
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
/* Palette cosinus type Iñigo Quilez : a + b*cos(6.28318*(c*t+d)) */
const fragmentShader = /* glsl */ `
uniform float u_time;
uniform vec2 u_resolution;
varying vec2 vUv;
vec3 palette(in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = (vUv - 0.5) * 2.0;
uv.x *= u_resolution.x / u_resolution.y;
vec2 uv0 = uv;
vec3 finalColor = vec3(0.0);
for (float i = 0.0; i < 3.0; i++) {
uv = fract(uv * 1.5) - 0.5;
float d = length(uv) * exp(-length(uv0));
vec3 col = palette(length(uv0) + i * 0.4 + u_time * 0.4,
vec3(0.5, 0.5, 0.9),
vec3(0.5, 0.5, 0.4),
vec3(1.0, 1.0, 0.9),
vec3(0.0, 0.1, 0.2));
d = sin(d * 8.0 + u_time) / 8.0;
d = abs(d);
d = pow(0.01 / d, 1.2);
finalColor += col * d;
}
gl_FragColor = vec4(finalColor, 1.0);
}
`;
let container: HTMLDivElement;
onMount(() => {
let cleanup: (() => void) | undefined;
let disposed = false;
(async () => {
// Import paresseux obligatoire : rien de Three au top-level (SSR).
const THREE = await import('three');
if (disposed || !container) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const width = container.offsetWidth || window.innerWidth;
const height = container.offsetHeight || window.innerHeight;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 2000);
camera.position.z = 1;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.domElement.style.display = 'block';
container.appendChild(renderer.domElement);
const uniforms = {
u_time: { value: 0.0 },
u_resolution: { value: new THREE.Vector2(width, height) }
};
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
let raf = 0;
const tick = () => {
uniforms.u_time.value += 0.008;
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
};
if (reduceMotion) {
// Mouvement réduit : une seule frame statique (u_time fixe), pas de boucle.
renderer.render(scene, camera);
} else {
// Frugalité : la boucle ne tourne que si le canvas est visible à l'écran
// ET si l'onglet est actif.
let inView = false;
let pageVisible = !document.hidden;
const stop = () => {
if (raf !== 0) {
cancelAnimationFrame(raf);
raf = 0;
}
};
const sync = () => {
if (inView && pageVisible) {
if (raf === 0) raf = requestAnimationFrame(tick);
} else {
stop();
}
};
const observer = new IntersectionObserver(
(entries) => {
inView = entries[0]?.isIntersecting ?? false;
sync();
},
{ threshold: 0 }
);
observer.observe(container);
const onVisibility = () => {
pageVisible = !document.hidden;
sync();
};
document.addEventListener('visibilitychange', onVisibility);
const resizeObserver = new ResizeObserver(() => {
const w = container.offsetWidth;
const h = container.offsetHeight;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
uniforms.u_resolution.value.set(w, h);
});
resizeObserver.observe(container);
cleanup = () => {
stop();
observer.disconnect();
resizeObserver.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
renderer.dispose();
geometry.dispose();
material.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
};
return;
}
// Chemin reduced-motion : pas d'observateurs d'animation, juste le resize.
const resizeObserver = new ResizeObserver(() => {
const w = container.offsetWidth;
const h = container.offsetHeight;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
uniforms.u_resolution.value.set(w, h);
renderer.render(scene, camera);
});
resizeObserver.observe(container);
cleanup = () => {
resizeObserver.disconnect();
renderer.dispose();
geometry.dispose();
material.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
};
})();
return () => {
disposed = true;
cleanup?.();
};
});
</script>
<div class="shader" bind:this={container} aria-hidden="true"></div>
<style>
.shader {
position: absolute;
inset: 0;
pointer-events: none;
}
</style>
@@ -0,0 +1,797 @@
<script lang="ts">
/* Graphe 3D de l'écosystème — Three.js + d3-force.
Les deux libs sont chargées paresseusement (await import dans onMount) :
aucun octet de Three/D3 dans le bundle initial. Topologie déterministe
issue de networkData (network.json pré-calculé, pas de Math.random de liens). */
import { onMount } from 'svelte';
import { categories, networkData, softwares } from '$lib/data';
import type { NetworkNode, Software } from '$lib/data';
import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from 'd3-force';
import { t } from '$lib/i18n/i18n.svelte';
type SimNode = NetworkNode & SimulationNodeDatum;
type SimLink = SimulationLinkDatum<SimNode> & { type: 'strong' | 'weak' };
interface TooltipState {
node: NetworkNode;
software?: Software;
x: number;
y: number;
pinned: boolean;
}
let viewport = $state<HTMLDivElement | null>(null);
// SSR / sans-JS : booting reste false → message de repli lisible (pas de
// squelette éternel). onMount le passe à true avant de charger les chunks.
let booting = $state(false);
let loading = $state(true);
let unavailable = $state(false);
let tooltip = $state<TooltipState | null>(null);
let filters = $state<Record<string, boolean>>(
Object.fromEntries(categories.map((c) => [c.id, true]))
);
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
// Assigné par onMount une fois les libs chargées.
let rebuildGraph: (() => void) | null = null;
function toggleFilter(categoryId: string) {
filters = { ...filters, [categoryId]: !filters[categoryId] };
tooltip = null;
rebuildGraph?.();
}
function closeTooltip() {
tooltip = null;
}
onMount(() => {
if (!viewport) return;
booting = true;
const container = viewport;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cancelled = false;
let disposeGraph: (() => void) | null = null;
let setPaused: ((paused: boolean) => void) | null = null;
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') tooltip = null;
};
window.addEventListener('keydown', onKeydown);
let ioPaused = false;
const io = new IntersectionObserver(
(entries) => {
ioPaused = !entries[0]?.isIntersecting;
setPaused?.(ioPaused || document.hidden);
},
{ threshold: 0.05 }
);
const onVisibility = () => setPaused?.(document.hidden || ioPaused);
(async () => {
try {
const THREE = await import('three');
const d3 = await import('d3-force');
if (cancelled) return;
// ————— Helpers couleurs : var(--…) → rgb utilisable par Three.js.
// THREE.Color ne comprend ni oklch() ni light-dark() : conversion manuelle.
const oklchToSrgb = (L: number, C: number, H: number): [number, number, number] => {
const hRad = (H * Math.PI) / 180;
const a = C * Math.cos(hRad);
const b = C * Math.sin(hRad);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3;
const m = m_ ** 3;
const s = s_ ** 3;
const to255 = (v: number): number => {
const c = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
return Math.round(Math.min(Math.max(c, 0), 1) * 255);
};
return [
to255(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
to255(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
to255(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
];
};
const toRgb = (color: string, fallback: string): string => {
const oklchMatch = color.match(
/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*[\d.]+%?)?\s*\)/
);
if (oklchMatch) {
const lightness = parseFloat(oklchMatch[1]) / (oklchMatch[2] ? 100 : 1);
const [r, g, b] = oklchToSrgb(
lightness,
parseFloat(oklchMatch[3]),
parseFloat(oklchMatch[4])
);
return `rgb(${r}, ${g}, ${b})`;
}
if (/^(#|rgb)/.test(color)) return color;
return fallback;
};
const cssColor = (varName: string, fallback: string): string => {
const probe = document.createElement('span');
probe.style.color = `var(${varName}, ${fallback})`;
probe.style.display = 'none';
container.appendChild(probe);
const computed = getComputedStyle(probe).color;
probe.remove();
return toRgb(computed, fallback);
};
const inkColor = cssColor('--ink', '#e8eef4');
const lineColor = cssColor('--border-strong', '#8a97a8');
// ————— Scène persistante —————
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60,
container.offsetWidth / Math.max(container.offsetHeight, 1),
0.1,
2000
);
camera.position.z = 100;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.offsetWidth, container.offsetHeight);
renderer.setClearColor(0x000000, 0);
renderer.domElement.style.display = 'block';
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const directional = new THREE.DirectionalLight(0xffffff, 1.4);
directional.position.set(40, 60, 80);
scene.add(directional);
const graphGroup = new THREE.Group();
scene.add(graphGroup);
// ————— État du sous-graphe courant —————
let instancedMesh: InstanceType<typeof THREE.InstancedMesh> | null = null;
let simulation: Simulation<SimNode, undefined> | null = null;
let currentNodes: SimNode[] = [];
const labelSprites = new Map<string, InstanceType<typeof THREE.Sprite>>();
const linkLines: InstanceType<typeof THREE.Line>[] = [];
const nodePositions = new Map<string, InstanceType<typeof THREE.Vector3>>();
const teardownSubgraph = () => {
simulation?.stop();
simulation = null;
graphGroup.traverse((obj) => {
if (obj instanceof THREE.InstancedMesh) {
obj.geometry.dispose();
(obj.material as import('three').Material).dispose();
} else if (obj instanceof THREE.Sprite) {
obj.material.map?.dispose();
obj.material.dispose();
} else if (obj instanceof THREE.Line) {
obj.geometry.dispose();
(obj.material as import('three').Material).dispose();
}
});
graphGroup.clear();
labelSprites.clear();
linkLines.length = 0;
nodePositions.clear();
instancedMesh = null;
currentNodes = [];
};
const applyPositions = (dummy: InstanceType<typeof THREE.Object3D>) => {
const mesh = instancedMesh;
if (!mesh) return;
currentNodes.forEach((node, i) => {
const x = node.x ?? 0;
const y = node.y ?? 0;
const z = node.z ?? 0;
const scale = (node.val || 5) * 0.4;
dummy.position.set(x, y, z);
dummy.scale.set(scale, scale, scale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
labelSprites.get(node.id)?.position.set(x, y + scale + 2, z);
nodePositions.set(node.id, new THREE.Vector3(x, y, z));
});
mesh.instanceMatrix.needsUpdate = true;
for (const line of linkLines) {
const src = nodePositions.get(line.userData.source as string);
const tgt = nodePositions.get(line.userData.target as string);
if (!src || !tgt) continue;
const attr = line.geometry.getAttribute(
'position'
) as import('three').BufferAttribute;
const arr = attr.array as Float32Array;
arr[0] = src.x; arr[1] = src.y; arr[2] = src.z;
arr[3] = tgt.x; arr[4] = tgt.y; arr[5] = tgt.z;
attr.needsUpdate = true;
}
};
const dummy = new THREE.Object3D();
const buildSubgraph = () => {
teardownSubgraph();
tooltip = null;
const activeCats = new Set(
Object.entries(filters)
.filter(([, v]) => v)
.map(([k]) => k)
);
currentNodes = networkData.nodes
.filter((n) => activeCats.has(n.category))
.map((n) => ({
...n,
x: (Math.random() - 0.5) * 60,
y: (Math.random() - 0.5) * 60,
z: (Math.random() - 0.5) * 30
}));
if (currentNodes.length === 0) {
renderer.render(scene, camera);
return;
}
const nodeIds = new Set(currentNodes.map((n) => n.id));
const links: SimLink[] = networkData.links
.filter(
(l) =>
nodeIds.has(l.source as string) && nodeIds.has(l.target as string)
)
.map((l) => ({
source: l.source as string,
target: l.target as string,
type: l.type
}));
// Nœuds (instanciés)
const geometry = new THREE.SphereGeometry(1, 16, 16);
const material = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0.1,
roughness: 0.3,
clearcoat: 1.0,
clearcoatRoughness: 0.1
});
instancedMesh = new THREE.InstancedMesh(geometry, material, currentNodes.length);
instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
currentNodes.forEach((node, i) => {
instancedMesh!.setColorAt(i, new THREE.Color(node.color || '#888888'));
});
if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true;
graphGroup.add(instancedMesh);
// Labels (un canvas par sprite — le partage de canvas entre
// CanvasTexture afficherait le dernier label partout)
for (const node of currentNodes) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.font = '500 24px "Space Grotesk", sans-serif';
ctx.fillStyle = inkColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.id, 128, 32);
}
const texture = new THREE.CanvasTexture(canvas);
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.85
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.scale.set(12, 3, 1);
graphGroup.add(sprite);
labelSprites.set(node.id, sprite);
}
// Liens
const lineMaterial = new THREE.LineBasicMaterial({
color: new THREE.Color(lineColor),
transparent: true,
opacity: 0.3
});
for (const link of links) {
const positions = new Float32Array(6);
const lineGeometry = new THREE.BufferGeometry();
lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const line = new THREE.Line(lineGeometry, lineMaterial);
line.userData = {
source: link.source,
target: link.target
};
graphGroup.add(line);
linkLines.push(line);
}
// Simulation (2D, z statique — parité avec la version React)
simulation = d3
.forceSimulation<SimNode>(currentNodes)
.force(
'link',
d3
.forceLink<SimNode, SimLink>(links)
.id((d) => d.id)
.distance((d) => (d.type === 'strong' ? 15 : 35))
)
.force('charge', d3.forceManyBody().strength(-100))
.force('center', d3.forceCenter(0, 0))
.force(
'collide',
d3
.forceCollide<SimNode>()
.radius((d) => (d.group === 1 ? 6 : 3) + 2)
.iterations(2)
)
.force('x', d3.forceX(0).strength(0.04))
.force('y', d3.forceY(0).strength(0.04))
.alphaDecay(0.02);
if (reducedMotion) {
// Convergence synchrone, une seule frame rendue.
simulation.stop();
for (let i = 0; i < 300; i++) simulation.tick();
applyPositions(dummy);
renderer.render(scene, camera);
} else {
simulation.on('tick', () => applyPositions(dummy));
}
};
// ————— Interactions —————
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const pick = (clientX: number, clientY: number): SimNode | null => {
if (!instancedMesh) return null;
const rect = container.getBoundingClientRect();
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObject(instancedMesh);
const id = hits[0]?.instanceId;
return id !== undefined ? (currentNodes[id] ?? null) : null;
};
const showTooltip = (node: SimNode, clientX: number, clientY: number, pinned: boolean) => {
const rect = container.getBoundingClientRect();
tooltip = {
node,
software: softwares.find((s) => s.name === node.id),
x: Math.min(clientX - rect.left + 20, rect.width - 280),
y: Math.min(clientY - rect.top + 20, rect.height - 220),
pinned
};
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== 'mouse' || tooltip?.pinned) return;
const node = pick(event.clientX, event.clientY);
if (node) {
showTooltip(node, event.clientX, event.clientY, false);
container.style.cursor = 'pointer';
} else {
tooltip = null;
container.style.cursor = 'default';
}
};
const onClick = (event: MouseEvent) => {
const node = pick(event.clientX, event.clientY);
if (node) {
showTooltip(node, event.clientX, event.clientY, true);
} else {
tooltip = null;
}
};
// ————— Boucle de rendu & pause —————
let rafId = 0;
let paused = false;
const animate = () => {
rafId = requestAnimationFrame(animate);
renderer.render(scene, camera);
};
setPaused = (next: boolean) => {
if (reducedMotion) return;
if (next === paused) return;
paused = next;
if (paused) {
cancelAnimationFrame(rafId);
} else {
rafId = requestAnimationFrame(animate);
}
};
const ro = new ResizeObserver(() => {
const w = container.offsetWidth;
const h = Math.max(container.offsetHeight, 1);
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (reducedMotion || paused) renderer.render(scene, camera);
});
ro.observe(container);
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('click', onClick);
document.addEventListener('visibilitychange', onVisibility);
buildSubgraph();
rebuildGraph = buildSubgraph;
loading = false;
if (!reducedMotion) animate();
disposeGraph = () => {
setPaused = null;
rebuildGraph = null;
cancelAnimationFrame(rafId);
ro.disconnect();
container.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('click', onClick);
document.removeEventListener('visibilitychange', onVisibility);
teardownSubgraph();
renderer.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
};
} catch {
if (!cancelled) {
unavailable = true;
loading = false;
}
}
})();
io.observe(container);
return () => {
cancelled = true;
io.disconnect();
window.removeEventListener('keydown', onKeydown);
disposeGraph?.();
};
});
</script>
<section id="map" aria-labelledby="map-title">
<div class="center section-head stack">
<p class="kicker">Cartographie</p>
<h2 id="map-title">L'Écosystème</h2>
<p class="subtitle">
{activeCount} catégories actives · Survolez ou touchez un nœud pour plus d'infos
</p>
</div>
<div class="graph-wrap center">
<div
class="viewport"
bind:this={viewport}
role="img"
aria-label="Graphe 3D des 105 logiciels du Fédiverse, colorés par catégorie. Le détail de chaque logiciel est aussi disponible dans le catalogue."
>
{#if !booting}
<div class="fallback stack">
<p>La carte 3D interactive nécessite JavaScript.</p>
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
</div>
{:else if loading}
<div class="skeleton" role="status">
<span>Chargement de la carte…</span>
</div>
{:else if unavailable}
<div class="fallback stack">
<p>La carte 3D n'est pas disponible dans ce navigateur.</p>
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
</div>
{/if}
</div>
<aside class="filters" aria-label="Filtres par catégorie">
<h3>Filtres</h3>
<ul class="stack" role="list">
{#each categories as cat (cat.id)}
<li>
<button
type="button"
class="filter-btn"
class:inactive={!filters[cat.id]}
aria-pressed={filters[cat.id]}
style:--cat-color={cat.color}
onclick={() => toggleFilter(cat.id)}
>
<span class="dot" aria-hidden="true"></span>
<span class="cat-name">{cat.name}</span>
</button>
</li>
{/each}
</ul>
</aside>
{#if tooltip}
<div
class="tooltip"
style:left="{tooltip.x}px"
style:top="{tooltip.y}px"
role={tooltip.pinned ? 'dialog' : 'status'}
aria-label={tooltip.pinned ? `Détails de ${tooltip.node.id}` : undefined}
>
<div class="tooltip-head">
<span class="dot glow" style:--cat-color={tooltip.node.color} aria-hidden="true"></span>
<h4>{tooltip.node.id}</h4>
{#if tooltip.pinned}
<button type="button" class="tooltip-close" onclick={closeTooltip} aria-label={t('common.close')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
{/if}
</div>
<p class="tooltip-desc">{tooltip.node.description}</p>
<dl class="tooltip-meta mono">
<div><dt>Licence</dt><dd>{tooltip.node.licence}</dd></div>
{#if tooltip.software}
<div><dt>Langage</dt><dd>{tooltip.software.language}</dd></div>
{/if}
<div><dt>État</dt><dd>{tooltip.node.etat}</dd></div>
<div><dt>ActivityPub</dt><dd>{tooltip.node.compatibiliteAP}</dd></div>
</dl>
</div>
{/if}
</div>
</section>
<style>
section {
background: var(--surface);
padding-block: var(--space-6);
scroll-margin-top: var(--header-h);
}
.section-head {
--stack-gap: var(--space-2);
margin-block-end: var(--space-4);
}
.kicker {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent-text);
}
.subtitle {
color: var(--muted);
}
.graph-wrap {
position: relative;
container-type: inline-size;
}
.viewport {
block-size: clamp(30rem, 85vh, 56rem);
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--surface-2);
overflow: hidden;
}
.skeleton {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.45;
}
}
.fallback {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: var(--space-3);
padding: var(--space-4);
text-align: center;
}
.fallback-link {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
text-decoration: none;
}
/* ————— Panneau de filtres ————— */
.filters {
margin-block-start: var(--space-3);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
padding: var(--space-3);
}
.filters h3 {
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-block-end: var(--space-2);
}
.filters ul {
--stack-gap: var(--space-1);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
gap: var(--space-1);
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
inline-size: 100%;
min-block-size: var(--tap-target);
padding-inline: var(--space-2);
border-radius: var(--radius-1);
color: var(--ink);
text-align: start;
}
.filter-btn:hover {
background: var(--surface-2);
}
.filter-btn .dot {
inline-size: 0.75rem;
block-size: 0.75rem;
border-radius: 50%;
background: var(--cat-color);
box-shadow: 0 0 0.5rem var(--cat-color);
flex-shrink: 0;
}
.filter-btn.inactive .dot {
background: var(--muted);
box-shadow: none;
}
.filter-btn.inactive .cat-name {
color: var(--muted);
text-decoration: line-through;
}
.cat-name {
font-size: var(--text-small);
}
@container (min-width: 56rem) {
.filters {
position: absolute;
inset-block-start: var(--space-3);
inset-inline-end: var(--space-3);
margin-block-start: 0;
max-inline-size: 15rem;
}
.filters ul {
display: flex;
flex-direction: column;
}
}
/* ————— Tooltip / panneau détail ————— */
.tooltip {
position: absolute;
z-index: 20;
max-inline-size: 17rem;
background: var(--glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
padding: var(--space-3);
box-shadow: var(--shadow-2);
pointer-events: auto;
}
.tooltip-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-block-end: var(--space-1);
}
.tooltip-head h4 {
font-size: var(--text-1);
flex: 1;
}
.dot.glow {
inline-size: 0.75rem;
block-size: 0.75rem;
border-radius: 50%;
background: var(--cat-color);
box-shadow: 0 0 0.5rem var(--cat-color);
flex-shrink: 0;
}
.tooltip-close {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2rem;
block-size: 2rem;
border-radius: var(--radius-1);
color: var(--ink);
}
.tooltip-desc {
font-size: var(--text-small);
color: var(--ink-soft);
margin-block-end: var(--space-2);
}
.tooltip-meta {
display: grid;
gap: var(--space-1);
font-size: var(--text-small);
}
.tooltip-meta div {
display: flex;
justify-content: space-between;
gap: var(--space-2);
}
.tooltip-meta dt {
color: var(--muted);
}
.tooltip-meta dd {
color: var(--ink);
text-align: end;
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
</style>
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/i18n/i18n.svelte';
let online = $state(true);
onMount(() => {
online = navigator.onLine;
const handleOnline = () => (online = true);
const handleOffline = () => (online = false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
});
</script>
{#if !online}
<div class="offline-pill" role="status">
<span class="dot" aria-hidden="true"></span>
{t('offline.indicator')}
</div>
{/if}
<style>
.offline-pill {
position: fixed;
inset-block-end: var(--space-3);
inset-inline-start: 50%;
translate: -50% 0;
z-index: 60;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--surface-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
color: var(--ink);
font-size: var(--text-small);
box-shadow: var(--shadow-2);
}
.dot {
inline-size: 0.625rem;
block-size: 0.625rem;
border-radius: 50%;
background: var(--status-maintenance);
}
</style>
@@ -0,0 +1,327 @@
<script lang="ts">
/* ProtocolSection — grille des 12 protocoles sur fond « data stream ». */
import { protocols, type Protocol } from '$lib/data';
import ProtocolStream from './ProtocolStream.svelte';
const TYPES: Record<Protocol['type'], { label: string; color: string }> = {
fediverse: { label: 'Protocole du Fédiverse', color: 'var(--accent-4-text)' },
connexe: { label: 'Protocole connexe', color: 'var(--status-maintenance)' },
voisin: { label: 'Écosystème voisin', color: 'var(--accent-text)' }
};
const TYPE_ORDER: Protocol['type'][] = ['fediverse', 'connexe', 'voisin'];
/* Couleur doublée par le texte du statut (jamais la couleur seule) */
const STATUS_COLORS: Record<Protocol['statut'], string> = {
Actif: 'var(--status-active)',
'En développement': 'var(--status-experimental)',
Historique: 'var(--status-historical)'
};
</script>
<section id="protocols" aria-labelledby="protocols-title">
<ProtocolStream />
<div class="veil" aria-hidden="true"></div>
<div class="content center stack">
<header class="section-head stack">
<p class="kicker">Standards &amp; Protocoles</p>
<h2 id="protocols-title">Les fondations du <span class="gradient">Fédiverse</span></h2>
<p class="subtitle">
Comprendre les protocoles qui permettent la communication décentralisée entre les
différentes plateformes.
</p>
</header>
<ul class="grid cards" role="list">
{#each protocols as proto (proto.id)}
<li>
<article class="card" style:--type-color={TYPES[proto.type].color}>
<div class="card-inner">
<div class="card-media">
<span class="icon-box">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#if proto.id === 'activitypub'}
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
{:else if proto.id === 'activitystreams'}
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" />
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
{:else if proto.id === 'webfinger'}
<path d="M2 9.5a15 15 0 0 1 20 0" />
<path d="M5 13a10 10 0 0 1 14 0" />
<path d="M8.5 16.5a5 5 0 0 1 7 0" />
<path d="M12 20h.01" />
{:else if proto.id === 'httpsignatures'}
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
{:else if proto.id === 'nodeinfo'}
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<path d="M6 6h.01" />
<path d="M6 18h.01" />
{:else if proto.id === 'zot'}
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
{:else if proto.id === 'diaspora'}
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
{:else if proto.id === 'ostatus'}
<path d="M4 11a9 9 0 0 1 9 9" />
<path d="M4 4a16 16 0 0 1 16 16" />
<circle cx="5" cy="19" r="1" />
{:else if proto.id === 'matrix'}
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
{:else if proto.id === 'xmpp'}
<circle cx="12" cy="12" r="2" />
<path d="M4.93 19.07a10 10 0 0 1 0-14.14" />
<path d="M7.76 16.24a6 6 0 0 1 0-8.49" />
<path d="M16.24 7.76a6 6 0 0 1 0 8.49" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
{:else if proto.id === 'atproto'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="m10 13-2 2 2 2" />
<path d="m14 17 2-2-2-2" />
{:else if proto.id === 'nostr'}
<circle cx="7.5" cy="15.5" r="5.5" />
<path d="m21 2-9.6 9.6" />
<path d="m15.5 7.5 3 3L22 7l-3-3" />
{:else}
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
{/if}
</svg>
</span>
</div>
<div class="card-body stack">
<div class="card-head cluster">
<h3>{proto.name}</h3>
<span class="badge">{TYPES[proto.type].label}</span>
<span class="status" style:--status-color={STATUS_COLORS[proto.statut]}>
<span class="dot" aria-hidden="true"></span>{proto.statut}
</span>
</div>
<p class="description">{proto.description}</p>
{#if proto.rfc}
<span class="rfc">{proto.rfc}</span>
{/if}
</div>
</div>
</article>
</li>
{/each}
</ul>
<ul class="legend cluster" role="list">
{#each TYPE_ORDER as type (type)}
<li class="cluster" style:--type-color={TYPES[type].color}>
<span class="dot" aria-hidden="true"></span>{TYPES[type].label}
</li>
{/each}
</ul>
</div>
</section>
<style>
section {
/* Contexte sombre local : light-dark() bascule en mode sombre ici. */
color-scheme: dark;
position: relative;
overflow: hidden;
background: var(--ocean);
color: var(--on-accent);
padding-block: var(--space-6);
scroll-margin-top: var(--header-h);
}
/* Voile de lisibilité au-dessus du data stream */
.veil {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
color-mix(in oklab, var(--ocean) 70%, transparent),
color-mix(in oklab, var(--ocean) 55%, transparent)
);
pointer-events: none;
}
.content {
position: relative;
z-index: 1;
--stack-gap: var(--space-5);
}
.section-head {
--stack-gap: var(--space-2);
align-items: center;
text-align: center;
}
.kicker {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent-2-text);
}
.gradient {
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle {
max-inline-size: var(--measure);
color: var(--ink-soft);
}
.cards {
--grid-min: 20rem;
--grid-gap: var(--space-3);
}
.card {
container-type: inline-size;
block-size: 100%;
background: var(--glass);
backdrop-filter: blur(0.5rem);
-webkit-backdrop-filter: blur(0.5rem);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
padding: var(--space-3);
transition:
transform var(--duration-2) var(--ease-out),
border-color var(--duration-2) var(--ease-out);
}
.card:hover {
transform: translateY(-2px);
border-color: var(--type-color);
}
.card-inner {
display: flex;
flex-direction: column;
gap: var(--space-3);
block-size: 100%;
}
/* Container query : carte large → layout horizontal (icône à gauche) */
@container (min-width: 28rem) {
.card-inner {
flex-direction: row;
align-items: flex-start;
}
}
.card-media {
flex-shrink: 0;
}
.icon-box {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--tap-target);
block-size: var(--tap-target);
border-radius: var(--radius-1);
color: var(--type-color);
background: color-mix(in oklab, var(--type-color) 12%, transparent);
}
.card-body {
--stack-gap: var(--space-2);
flex: 1;
}
.card-head {
--cluster-gap: var(--space-2);
}
h3 {
font-size: var(--text-1);
}
.badge {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--type-color);
background: color-mix(in oklab, var(--type-color) 12%, transparent);
border: 1px solid color-mix(in oklab, var(--type-color) 30%, transparent);
border-radius: var(--radius-pill);
padding: var(--space-1) var(--space-2);
}
.status {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-small);
color: var(--ink-soft);
}
.dot {
inline-size: 0.625rem;
block-size: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--status-color, var(--type-color));
box-shadow: 0 0 0.375rem var(--status-color, var(--type-color));
}
.description {
font-size: var(--text-small);
color: var(--ink-soft);
}
.rfc {
align-self: flex-start;
font-family: var(--font-mono);
font-size: var(--text-small);
color: var(--muted);
background: color-mix(in oklab, var(--on-accent) 6%, transparent);
border-radius: var(--radius-1);
padding: var(--space-1) var(--space-2);
}
.legend {
--cluster-gap: var(--space-4);
justify-content: center;
font-size: var(--text-small);
color: var(--ink-soft);
}
.legend .dot {
background: var(--type-color);
box-shadow: 0 0 0.375rem var(--type-color);
}
@media (prefers-reduced-motion: reduce) {
.card,
.card:hover {
transform: none;
}
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
/* ProtocolStream — colonnes de « data stream » défilant en boucle,
purement décoratives (aria-hidden). Aucune animation si mouvement réduit. */
const LINES = [
'ActivityPub v1.0',
'{ "type": "Create", "actor": "https://..." }',
'W3C Recommendation',
'WebFinger RFC 7033',
'HTTP Signatures',
'NodeInfo 2.0',
'ActivityStreams 2.0',
'application/activity+json',
'followers_collection',
'inbox → outbox',
'FEP-8b32: Long Objects',
'FEP-5624: Per-Event Push',
'@user@instance.social',
'fediverse:culture',
'2026-01-14T09:30:00Z',
'public_key: RSA 2048',
'LD-Signatures',
'content-type: application/ld+json',
'as:Note, as:Article',
'sharedInbox enabled'
] as const;
/* Contenu doublé pour une boucle seamless (translateY(-50%) → 0) */
const DOUBLED = [...LINES, ...LINES];
const COLUMNS = [
{ left: '-10%', duration: '20s', delay: '0s' },
{ left: '15%', duration: '28s', delay: '3s' },
{ left: '40%', duration: '24s', delay: '7s' },
{ left: '65%', duration: '32s', delay: '1s' }
] as const;
</script>
<div class="stream" aria-hidden="true">
{#each COLUMNS as col (col.left)}
<div
class="column"
style:--left={col.left}
style:--duration={col.duration}
style:--delay={col.delay}
>
{#each DOUBLED as line, i (i)}
<span class="line">{line}</span>
{/each}
</div>
{/each}
</div>
<style>
.stream {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.column {
position: absolute;
inset-block-start: 0;
inset-inline-start: var(--left);
inline-size: 25%;
display: flex;
flex-direction: column;
font-family: var(--font-mono);
font-size: var(--text-small);
color: var(--accent);
opacity: 0.3;
animation: stream-flow var(--duration) linear infinite;
animation-delay: var(--delay);
will-change: transform;
}
.line {
white-space: nowrap;
padding-inline: var(--space-2);
margin-block-end: var(--space-3);
}
@keyframes stream-flow {
from {
transform: translateY(-50%) skewX(-20deg);
}
to {
transform: translateY(0) skewX(-20deg);
}
}
@media (prefers-reduced-motion: reduce) {
.column {
animation: none;
}
}
</style>
@@ -0,0 +1,140 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/i18n/i18n.svelte';
import { isTypingTarget } from '$lib/keyboard';
let open = $state(false);
let dialogEl = $state<HTMLElement | null>(null);
onMount(() => {
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
open = false;
return;
}
if (event.key === '?' && !isTypingTarget(event.target)) {
event.preventDefault();
open = !open;
}
};
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
});
$effect(() => {
if (open) {
dialogEl?.querySelector<HTMLElement>('button')?.focus();
}
});
const shortcuts = [
{ keys: ['/'], descKey: 'shortcuts.search' },
{ keys: ['?'], descKey: 'shortcuts.help' },
{ keys: ['j'], descKey: 'shortcuts.next' },
{ keys: ['k'], descKey: 'shortcuts.prev' },
{ keys: ['Échap'], descKey: 'shortcuts.close' }
] as const;
</script>
{#if open}
<div
class="backdrop"
onclick={() => (open = false)}
onkeydown={(e) => e.key === 'Escape' && (open = false)}
role="presentation"
></div>
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
bind:this={dialogEl}
>
<div class="dialog-header cluster">
<h2 id="shortcuts-title">{t('shortcuts.title')}</h2>
<button type="button" class="close-btn" onclick={() => (open = false)} aria-label={t('common.close')}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<ul class="stack" role="list">
{#each shortcuts as shortcut (shortcut.descKey)}
<li class="shortcut-row">
<span class="cluster keys">
{#each shortcut.keys as key (key)}
<kbd>{key}</kbd>
{/each}
</span>
<span>{t(shortcut.descKey)}</span>
</li>
{/each}
</ul>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: oklch(10% 0.02 260 / 0.6);
backdrop-filter: blur(4px);
}
.dialog {
position: fixed;
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
z-index: 90;
inline-size: min(26rem, calc(100vw - 2 * var(--space-3)));
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-2);
padding: var(--space-4);
box-shadow: var(--shadow-2);
}
.dialog-header {
justify-content: space-between;
margin-block-end: var(--space-3);
}
.close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--tap-target);
block-size: var(--tap-target);
border-radius: var(--radius-1);
color: var(--ink);
}
.dialog ul {
--stack-gap: var(--space-2);
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
color: var(--ink-soft);
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: 2rem;
min-block-size: 2rem;
padding-inline: var(--space-2);
background: var(--surface-3);
border: 1px solid var(--border-strong);
border-block-end-width: 2px;
border-radius: var(--radius-1);
font-family: var(--font-mono);
color: var(--ink);
}
</style>
@@ -0,0 +1,848 @@
<script lang="ts">
import { onMount } from 'svelte';
import { categoryById, ETATS, softwares, type Category, type Software } from '$lib/data';
import { t } from '$lib/i18n/i18n.svelte';
import { isTypingTarget } from '$lib/keyboard';
type StatusFilter = Software['etat'] | 'all';
const STORAGE_KEY = 'atlas-catalog-filters';
const statusColor: Record<Software['etat'], string> = {
Actif: 'var(--status-active)',
Maintenance: 'var(--status-maintenance)',
Experimental: 'var(--status-experimental)',
Historique: 'var(--status-historical)'
};
const compatColor: Record<Software['compatibiliteAP'], string> = {
Pleine: 'var(--compat-full)',
Partielle: 'var(--compat-partial)',
Extension: 'var(--compat-extension)',
Aucune: 'var(--compat-none)',
'En développement': 'var(--compat-none)'
};
/** Choisit un texte contrasté (clair ou foncé) selon la luminance WCAG d'une couleur hex. */
function contrastingInk(hex: string | undefined): string {
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return 'var(--on-accent)';
const channels = [1, 3, 5].map((start) => {
const c = parseInt(hex.slice(start, start + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
const luminance = 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
return luminance > 0.18 ? 'oklch(22% 0.02 260)' : 'var(--on-accent)';
}
function categoryOf(software: Software): Category | undefined {
return categoryById.get(software.category);
}
function categoryName(software: Software): string {
return categoryOf(software)?.name ?? software.category;
}
let search = $state('');
let activeCategory = $state('all');
let activeStatus = $state<StatusFilter>('all');
let selectedSoftware = $state<Software | null>(null);
let searchInput = $state<HTMLInputElement | null>(null);
let gridEl = $state<HTMLElement | null>(null);
let closeButton = $state<HTMLButtonElement | null>(null);
let triggerElement: HTMLElement | null = null;
const filtered = $derived.by(() => {
const query = search.trim().toLowerCase();
const result = softwares.filter((software) => {
if (activeCategory !== 'all' && software.category !== activeCategory) return false;
if (activeStatus !== 'all' && software.etat !== activeStatus) return false;
if (query) {
const haystack =
`${software.name} ${software.description} ${software.category} ${categoryName(software)} ${software.language}`.toLowerCase();
if (!haystack.includes(query)) return false;
}
return true;
});
result.sort((a, b) => {
const statusDiff = ETATS.indexOf(a.etat) - ETATS.indexOf(b.etat);
return statusDiff !== 0 ? statusDiff : a.name.localeCompare(b.name);
});
return result;
});
function resetFilters(): void {
search = '';
activeCategory = 'all';
activeStatus = 'all';
}
function openDetails(software: Software, trigger: HTMLElement | null): void {
triggerElement = trigger;
selectedSoftware = software;
}
function closeDetails(): void {
selectedSoftware = null;
triggerElement?.focus();
triggerElement = null;
}
onMount(() => {
// Reprennabilité : restaure les filtres de la session
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const saved = JSON.parse(raw) as Record<string, unknown>;
if (typeof saved.search === 'string') search = saved.search;
if (
saved.activeCategory === 'all' ||
(typeof saved.activeCategory === 'string' && categoryById.has(saved.activeCategory))
) {
activeCategory = saved.activeCategory as string;
}
if (
saved.activeStatus === 'all' ||
(typeof saved.activeStatus === 'string' &&
(ETATS as readonly string[]).includes(saved.activeStatus))
) {
activeStatus = saved.activeStatus as StatusFilter;
}
}
} catch {
/* stockage indisponible ou données corrompues : on ignore */
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (selectedSoftware) closeDetails();
return;
}
if (selectedSoftware) return;
if (isTypingTarget(event.target) || event.ctrlKey || event.metaKey || event.altKey) return;
if (event.key === '/') {
event.preventDefault();
searchInput?.focus();
} else if (event.key === 'j' || event.key === 'k') {
const cards = gridEl ? [...gridEl.querySelectorAll<HTMLElement>('.card')] : [];
if (cards.length === 0) return;
event.preventDefault();
const delta = event.key === 'j' ? 1 : -1;
const current = cards.indexOf(document.activeElement as HTMLElement);
const next =
current === -1
? delta === 1
? 0
: cards.length - 1
: (current + delta + cards.length) % cards.length;
cards[next].focus();
}
};
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
});
// Persiste les filtres pour la session
$effect(() => {
const snapshot = { search, activeCategory, activeStatus };
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
/* stockage indisponible : on ignore */
}
});
// Focus sur le bouton fermer à l'ouverture de la modale
$effect(() => {
if (selectedSoftware) closeButton?.focus();
});
</script>
<section id="catalog" aria-labelledby="catalog-title">
<div class="inner center">
<header class="section-header stack">
<p class="kicker">Répertoire</p>
<h2 id="catalog-title">Les logiciels du <span class="gradient-text">Fédiverse</span></h2>
<p class="subtitle">
{softwares.length} logiciels référencés, classés par catégorie et filtrables par statut
d'activité.
</p>
</header>
<div class="toolbar">
<div class="search-field">
<label class="visually-hidden" for="catalog-search">{t('common.search')}</label>
<svg
class="search-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
<path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<input
id="catalog-search"
type="search"
bind:this={searchInput}
bind:value={search}
placeholder="Rechercher un logiciel, un langage…"
autocomplete="off"
/>
{#if search}
<button
type="button"
class="clear-btn"
aria-label={t('common.clear')}
onclick={() => {
search = '';
searchInput?.focus();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
{/if}
</div>
<div class="selects cluster">
<label class="select-label">
<span class="visually-hidden">Filtrer par catégorie</span>
<select bind:value={activeCategory}>
<option value="all">Toutes les catégories</option>
{#each [...categoryById.values()] as category (category.id)}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</label>
<label class="select-label">
<span class="visually-hidden">Filtrer par statut</span>
<select bind:value={activeStatus}>
<option value="all">Tous les statuts</option>
{#each ETATS as etat (etat)}
<option value={etat}>{etat}</option>
{/each}
</select>
</label>
</div>
</div>
<p class="results-count" role="status">
{t('catalog.results', { count: filtered.length })}<!--
-->{#if activeCategory !== 'all'}
dans <strong>{categoryById.get(activeCategory)?.name ?? activeCategory}</strong>{/if}<!--
-->{#if activeStatus !== 'all'}
· statut : <strong>{activeStatus}</strong>{/if}<!--
-->{#if search.trim()}
pour «&nbsp;<strong>{search.trim()}</strong>&nbsp;»{/if}
</p>
{#if filtered.length === 0}
<div class="empty stack">
<p>{t('common.emptyResults')}</p>
<button type="button" class="reset-btn" onclick={resetFilters}>
{t('common.resetFilters')}
</button>
</div>
{:else}
<div class="grid catalog-grid" bind:this={gridEl}>
{#each filtered as software (software.id)}
{@const category = categoryOf(software)}
<button
type="button"
class="card"
style:--cat-color={category?.color ?? 'var(--muted)'}
style:--tile-ink={contrastingInk(category?.color)}
style:--status-color={statusColor[software.etat]}
style:--compat-color={compatColor[software.compatibiliteAP]}
aria-haspopup="dialog"
onclick={(event) => openDetails(software, event.currentTarget)}
>
<span class="card-inner">
<span class="tile" aria-hidden="true">{software.name.charAt(0)}</span>
<span class="card-body">
<span class="card-head">
<span class="card-name">{software.name}</span>
<span class="card-cat">{categoryName(software)}</span>
</span>
<span class="status">
<span class="status-dot" aria-hidden="true"></span>
{software.etat}
</span>
<span class="card-desc">{software.description}</span>
<span class="chips">
<span class="chip">{software.language}</span>
<span class="chip compat">AP&nbsp;: {software.compatibiliteAP}</span>
</span>
</span>
</span>
</button>
{/each}
</div>
<p class="end-marker">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="5" r="3" />
<path d="M12 8v13" />
<path d="M5 12H2a10 10 0 0 0 20 0h-3" />
</svg>
{t('catalog.upToDate')} · {t('catalog.upToDateCreole')}
</p>
{/if}
</div>
</section>
{#if selectedSoftware}
{@const software = selectedSoftware}
{@const category = categoryOf(software)}
<div class="backdrop" onclick={closeDetails} role="presentation"></div>
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="software-dialog-title"
style:--cat-color={category?.color ?? 'var(--muted)'}
style:--tile-ink={contrastingInk(category?.color)}
style:--status-color={statusColor[software.etat]}
style:--compat-color={compatColor[software.compatibiliteAP]}
>
<button
type="button"
class="close-btn"
bind:this={closeButton}
aria-label={t('common.close')}
onclick={closeDetails}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<div class="dialog-head">
<span class="tile tile-lg" aria-hidden="true">{software.name.charAt(0)}</span>
<span class="dialog-title-block">
<h3 id="software-dialog-title">{software.name}</h3>
<span class="badges">
<span class="chip cat-chip">{categoryName(software)}</span>
<span class="status">
<span class="status-dot" aria-hidden="true"></span>
{software.etat}
</span>
</span>
</span>
</div>
<p class="dialog-desc">{software.description}</p>
<dl class="details">
<div class="detail-row">
<dt>Protocoles</dt>
<dd>{software.protocols.join(', ')}</dd>
</div>
<div class="detail-row">
<dt>Licence</dt>
<dd>{software.licence}</dd>
</div>
<div class="detail-row">
<dt>Langage</dt>
<dd>{software.language}</dd>
</div>
<div class="detail-row">
<dt>ActivityPub</dt>
<dd class="compat-value">{software.compatibiliteAP}</dd>
</div>
{#if software.version}
<div class="detail-row">
<dt>Version</dt>
<dd>{software.version}</dd>
</div>
{/if}
{#if software.createdYear}
<div class="detail-row">
<dt>Créé en</dt>
<dd>{software.createdYear}</dd>
</div>
{/if}
</dl>
<div class="dialog-actions">
{#if software.site}
<a href={software.site} target="_blank" rel="external noopener" class="btn-primary">
{t('common.officialSite')}
</a>
{/if}
{#if software.github}
<a href={software.github} target="_blank" rel="external noopener" class="btn-outline">
{t('common.sourceCode')}
</a>
{/if}
</div>
</div>
{/if}
<style>
section {
background: var(--surface);
padding-block: var(--space-6);
scroll-margin-top: var(--header-h);
}
.section-header {
--stack-gap: var(--space-3);
align-items: center;
text-align: center;
margin-block-end: var(--space-5);
}
.kicker {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--accent-text);
}
.gradient-text {
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle {
color: var(--ink-soft);
max-inline-size: var(--measure);
}
/* --- Barre d'outils --- */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-block-end: var(--space-3);
}
.search-field {
position: relative;
flex: 1 1 18rem;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
inset-inline-start: var(--space-3);
color: var(--muted);
pointer-events: none;
}
.search-field input {
inline-size: 100%;
min-block-size: var(--tap-target);
padding-inline-start: calc(var(--space-3) * 2 + 1.125rem);
padding-inline-end: var(--tap-target);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-2);
color: var(--ink);
transition: border-color var(--duration-1) var(--ease-out);
}
.search-field input::placeholder {
color: var(--muted);
}
.search-field input:focus-visible {
border-color: var(--accent);
}
.clear-btn {
position: absolute;
inset-inline-end: var(--space-1);
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: calc(var(--tap-target) - var(--space-2));
block-size: calc(var(--tap-target) - var(--space-2));
border-radius: var(--radius-1);
color: var(--muted);
}
.clear-btn:hover {
color: var(--ink);
background: var(--surface-3);
}
.selects {
gap: var(--space-2);
}
.select-label select {
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-2);
color: var(--ink);
}
.select-label select:focus-visible {
border-color: var(--accent);
}
/* --- Compteur --- */
.results-count {
font-size: var(--text-small);
color: var(--muted);
margin-block-end: var(--space-4);
}
.results-count strong {
color: var(--ink);
}
/* --- État vide --- */
.empty {
--stack-gap: var(--space-3);
align-items: center;
padding-block: var(--space-6);
text-align: center;
color: var(--muted);
}
.reset-btn {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--accent-text);
border-radius: var(--radius-pill);
color: var(--accent-text);
font-weight: 600;
}
.reset-btn:hover {
background: color-mix(in oklch, var(--accent) 12%, transparent);
}
/* --- Grille et cartes --- */
.catalog-grid {
--grid-min: 19rem;
}
.card {
container-type: inline-size;
display: block;
inline-size: 100%;
padding: var(--space-4);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
box-shadow: var(--shadow-1);
text-align: start;
transition:
border-color var(--duration-1) var(--ease-out),
box-shadow var(--duration-1) var(--ease-out),
transform var(--duration-1) var(--ease-out);
}
.card:hover {
border-color: color-mix(in oklch, var(--cat-color) 55%, var(--glass-border));
box-shadow: var(--shadow-2);
transform: translateY(-2px);
}
.card-inner {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.tile {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.75rem;
block-size: 2.75rem;
border-radius: var(--radius-1);
background: var(--cat-color);
color: var(--tile-ink);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--text-2);
}
.card-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-inline-size: 0;
}
.card-head {
display: flex;
flex-direction: column;
}
.card-name {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--text-1);
color: var(--ink);
}
.card-cat {
font-family: var(--font-mono);
font-size: var(--text-small);
color: var(--muted);
}
.status {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-small);
color: var(--ink-soft);
}
.status-dot {
flex-shrink: 0;
inline-size: 0.625rem;
block-size: 0.625rem;
border-radius: 50%;
background: var(--status-color);
box-shadow: 0 0 0.4rem color-mix(in oklch, var(--status-color) 60%, transparent);
}
.card-desc {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: var(--text-small);
line-height: 1.6;
color: var(--ink-soft);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-1);
background: var(--surface-3);
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: var(--text-small);
color: var(--ink-soft);
}
.chip.compat {
background: color-mix(in oklch, var(--compat-color) 14%, transparent);
border-color: color-mix(in oklch, var(--compat-color) 45%, transparent);
color: var(--compat-color);
}
/* Container query : tuile au-dessus quand la carte est étroite */
@container (max-width: 15rem) {
.card-inner {
flex-direction: column;
}
}
/* --- Marqueur de fin (pas de scroll infini) --- */
.end-marker {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-block-start: var(--space-5);
padding-block-start: var(--space-4);
border-block-start: 1px solid var(--border);
font-size: var(--text-small);
color: var(--muted);
}
/* --- Modale détail --- */
.backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: oklch(10% 0.02 260 / 0.6);
backdrop-filter: blur(4px);
}
.dialog {
position: fixed;
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
z-index: 90;
inline-size: min(32rem, calc(100vw - 2 * var(--space-3)));
max-block-size: 85dvh;
overflow-y: auto;
padding: var(--space-4);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-2);
box-shadow: var(--shadow-2);
}
.close-btn {
position: absolute;
inset-block-start: var(--space-2);
inset-inline-end: var(--space-2);
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--tap-target);
block-size: var(--tap-target);
border-radius: var(--radius-1);
color: var(--ink);
}
.close-btn:hover {
background: var(--surface-3);
}
.dialog-head {
display: flex;
align-items: center;
gap: var(--space-3);
margin-block-end: var(--space-3);
padding-inline-end: var(--tap-target);
}
.tile-lg {
inline-size: 3.5rem;
block-size: 3.5rem;
font-size: var(--text-3);
border-radius: var(--radius-2);
}
.dialog-title-block {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-inline-size: 0;
}
.badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.cat-chip {
background: color-mix(in oklch, var(--cat-color) 16%, transparent);
border-color: color-mix(in oklch, var(--cat-color) 50%, transparent);
}
.dialog-desc {
font-size: var(--text-small);
line-height: 1.6;
color: var(--ink-soft);
margin-block-end: var(--space-4);
}
.details {
font-family: var(--font-mono);
font-size: var(--text-small);
margin-block-end: var(--space-4);
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-3);
padding-block: var(--space-2);
border-block-end: 1px solid var(--border);
}
.detail-row dt {
color: var(--muted);
flex-shrink: 0;
}
.detail-row dd {
color: var(--ink);
text-align: end;
overflow-wrap: anywhere;
}
.compat-value {
color: var(--compat-color);
}
.dialog-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.dialog-actions a {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border-radius: var(--radius-1);
font-weight: 600;
text-decoration: none;
}
.btn-primary {
flex: 1 1 auto;
background: var(--accent);
color: var(--on-accent);
}
.btn-outline {
border: 1px solid var(--border-strong);
color: var(--ink);
}
.btn-outline:hover {
background: var(--surface-3);
}
</style>
@@ -0,0 +1,369 @@
<script lang="ts">
import { networkData, stats } from '$lib/data';
import { timelineEvents, type TimelineEvent } from '$lib/data/timeline';
type CategoryKey = TimelineEvent['category'];
type FilterKey = CategoryKey | 'all';
/* Couleurs de catégories : tokens sémantiques (adaptés au thème clair/sombre). */
const categoryConfig = {
foundation: { label: 'Fondation', color: 'var(--muted)' },
protocol: { label: 'Protocole', color: 'var(--accent)' },
software: { label: 'Logiciel', color: 'var(--accent-2)' },
milestone: { label: 'Jalon', color: 'var(--status-maintenance)' },
ecosystem: { label: 'Écosystème', color: 'var(--accent-4)' }
} as const satisfies Record<CategoryKey, { label: string; color: string }>;
const categoryFilters: readonly { key: FilterKey; label: string; color: string }[] = [
{ key: 'all', label: 'Tout', color: 'var(--accent)' },
{ key: 'foundation', label: 'Fondations', color: categoryConfig.foundation.color },
{ key: 'protocol', label: 'Protocoles', color: categoryConfig.protocol.color },
{ key: 'software', label: 'Logiciels', color: categoryConfig.software.color },
{ key: 'milestone', label: 'Jalons', color: categoryConfig.milestone.color },
{ key: 'ecosystem', label: 'Écosystème', color: categoryConfig.ecosystem.color }
];
let activeFilter = $state<FilterKey>('all');
const filtered = $derived(
activeFilter === 'all'
? timelineEvents
: timelineEvents.filter((event) => event.category === activeFilter)
);
const yearGroups = $derived.by(() => {
const byYear = new Map<number, TimelineEvent[]>();
for (const event of filtered) {
const group = byYear.get(event.year);
if (group) group.push(event);
else byYear.set(event.year, [event]);
}
return [...byYear.entries()].sort((a, b) => a[0] - b[0]);
});
const finalStats = [
{ value: stats.yearsOfHistory, label: "Années d'histoire" },
{ value: stats.totalSoftwares, label: 'Logiciels référencés' },
{ value: stats.totalProtocols, label: 'Protocoles' },
{ value: networkData.links.length, label: 'Liens de fédération' }
];
</script>
<section id="timeline" aria-labelledby="timeline-title">
<div class="inner center">
<header class="section-header stack">
<p class="kicker">🕐 Chronologie</p>
<h2 id="timeline-title">L'évolution du <span class="gradient-text">Fédiverse</span></h2>
<p class="subtitle">
De StatusNet en 2008 à l'Atlas de 2026 : {stats.yearsOfHistory} ans d'histoire du réseau
social décentralisé.
</p>
</header>
<div class="filters cluster" role="group" aria-label="Filtrer la chronologie par catégorie">
{#each categoryFilters as filter (filter.key)}
<button
type="button"
class="pill"
style:--cat-color={filter.color}
aria-pressed={activeFilter === filter.key}
onclick={() => (activeFilter = filter.key)}
>
{filter.label}
</button>
{/each}
</div>
<div class="timeline">
{#each yearGroups as [year, events] (year)}
<section class="year-group" aria-labelledby="timeline-year-{year}">
<h3 class="year-badge" id="timeline-year-{year}">{year}</h3>
<ol class="events" role="list">
{#each events as event, i (`${year}-${event.title}`)}
{@const cat = categoryConfig[event.category]}
<li class="event" class:left={i % 2 === 0} class:right={i % 2 === 1}>
<article class="event-card" style:--event-color={event.color}>
<p class="event-meta">
<span class="event-dot" aria-hidden="true"></span>
<span class="event-cat" style:--cat-label-color={cat.color}>{cat.label}</span>
</p>
<h4 class="event-title">{event.title}</h4>
<p class="event-desc">{event.description}</p>
</article>
</li>
{/each}
</ol>
</section>
{/each}
</div>
<div class="grid stats" role="list">
{#each finalStats as stat (stat.label)}
<div class="stat-card" role="listitem">
<p class="stat-value gradient-text">{stat.value}</p>
<p class="stat-label">{stat.label}</p>
</div>
{/each}
</div>
</div>
</section>
<style>
section {
background: var(--abyss);
padding-block: var(--space-6);
scroll-margin-top: var(--header-h);
}
.inner {
--center-max: 64rem;
}
.section-header {
--stack-gap: var(--space-3);
align-items: center;
text-align: center;
margin-block-end: var(--space-5);
}
.kicker {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--status-maintenance);
}
.gradient-text {
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle {
color: var(--ink-soft);
max-inline-size: var(--measure);
}
/* --- Filtres --- */
.filters {
justify-content: center;
margin-block-end: var(--space-5);
}
.pill {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
font-size: var(--text-small);
font-weight: 500;
color: var(--ink-soft);
transition:
background-color var(--duration-1) var(--ease-out),
border-color var(--duration-1) var(--ease-out),
color var(--duration-1) var(--ease-out);
}
.pill:hover {
border-color: var(--cat-color);
color: var(--ink);
}
.pill[aria-pressed='true'] {
background: color-mix(in oklch, var(--cat-color) 22%, transparent);
border-color: var(--cat-color);
color: var(--ink);
font-weight: 600;
}
/* --- Chronologie --- */
.timeline {
position: relative;
container-type: inline-size;
}
/* Ligne verticale en dégradé : à gauche sur mobile, centrée sur desktop */
.timeline::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: var(--space-3);
translate: -50% 0;
inline-size: 2px;
background: linear-gradient(180deg, var(--accent), var(--accent-2), var(--accent-4));
}
.year-group {
position: relative;
margin-block-end: var(--space-5);
}
.year-badge {
position: relative;
z-index: 1;
inline-size: fit-content;
margin-inline: auto;
margin-block-end: var(--space-4);
padding: var(--space-2) var(--space-3);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-pill);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-family: var(--font-mono);
font-size: var(--text-1);
color: var(--ink);
}
.events {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.event {
position: relative;
padding-inline-start: var(--space-5);
}
/* Pastille sur la ligne */
.event::before {
content: '';
position: absolute;
inset-inline-start: var(--space-3);
inset-block-start: var(--space-4);
translate: -50% 0;
inline-size: 0.75rem;
block-size: 0.75rem;
border-radius: 50%;
background: var(--event-color, var(--accent));
border: 2px solid var(--abyss);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--event-color, var(--accent)) 45%, transparent);
}
.event-card {
inline-size: fit-content;
max-inline-size: 100%;
padding: var(--space-4);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
box-shadow: var(--shadow-1);
}
.event-meta {
display: flex;
align-items: center;
gap: var(--space-2);
margin-block-end: var(--space-2);
}
.event-dot {
flex-shrink: 0;
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: 50%;
background: var(--event-color, var(--accent));
box-shadow: 0 0 0.5rem color-mix(in oklch, var(--event-color, var(--accent)) 70%, transparent);
}
.event-cat {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--cat-label-color);
}
.event-title {
font-size: var(--text-1);
margin-block-end: var(--space-1);
}
.event-desc {
font-size: var(--text-small);
color: var(--ink-soft);
}
/* Alternance gauche/droite sur desktop */
@media (min-width: 48rem) {
.timeline::before {
inset-inline-start: 50%;
}
.event {
inline-size: calc(50% - var(--space-5));
padding-inline-start: 0;
}
.event.left {
margin-inline-end: auto;
text-align: end;
}
.event.left .event-card {
margin-inline-start: auto;
}
.event.left .event-meta {
justify-content: flex-end;
}
.event.left::before {
inset-inline-start: auto;
inset-inline-end: calc(-1 * var(--space-5));
translate: 50% 0;
}
.event.right {
margin-inline-start: auto;
}
.event.right::before {
inset-inline-start: calc(-1 * var(--space-5));
}
}
/* Container query : carte compacte quand la chronologie est étroite */
@container (max-width: 30rem) {
.event-card {
padding: var(--space-3);
}
.event-desc {
font-size: var(--text-small);
}
}
/* --- Statistiques finales --- */
.stats {
--grid-min: 11rem;
margin-block-start: var(--space-6);
}
.stat-card {
padding: var(--space-3);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
text-align: center;
}
.stat-value {
font-family: var(--font-display);
font-size: var(--text-3);
font-weight: 700;
}
.stat-label {
font-size: var(--text-small);
color: var(--ink-soft);
}
</style>
@@ -0,0 +1,133 @@
<script lang="ts">
export interface CompareRow {
aspect: string;
values: string[];
}
interface Props {
/** Légende visible du tableau (sert aussi de description accessible). */
caption: string;
/** Libellé de la première colonne (en-têtes de ligne). */
firstColumn?: string;
/** En-têtes des colonnes de données. */
columns: string[];
rows: CompareRow[];
/** Index dans `columns` de la colonne mise en valeur (-1 = aucune). */
highlight?: number;
/** Étiquette de la région de défilement horizontal. */
ariaLabel: string;
}
let { caption, firstColumn = 'Aspect', columns, rows, highlight = -1, ariaLabel }: Props = $props();
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex — région défilante : tabindex="0" la rend
accessible au clavier (défilement horizontal du tableau), c'est voulu. -->
<div class="table-scroll" role="region" aria-label={ariaLabel} tabindex="0">
<table>
<caption>{caption}</caption>
<thead>
<tr>
<th scope="col">{firstColumn}</th>
{#each columns as column, i (column)}
<th scope="col" class:highlight={i === highlight}>{column}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row (row.aspect)}
<tr>
<th scope="row">{row.aspect}</th>
{#each row.values as value, i (i)}
<td class:highlight={i === highlight}>{value}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.table-scroll {
overflow-x: auto;
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
table {
inline-size: 100%;
min-inline-size: 34rem;
border-collapse: collapse;
font-size: var(--text-small);
}
caption {
text-align: start;
padding: var(--space-3) var(--space-3) 0;
color: var(--ink-soft);
font-size: var(--text-small);
line-height: 1.6;
max-inline-size: var(--measure);
}
th,
td {
padding: var(--space-3);
text-align: start;
vertical-align: top;
border-block-end: 1px solid var(--border);
}
tbody tr:last-child th,
tbody tr:last-child td {
border-block-end: 0;
}
thead th {
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
border-block-end-color: var(--border-strong);
}
tbody th {
font-weight: 600;
color: var(--ink);
}
td {
color: var(--ink-soft);
}
/* Colonne mise en valeur : couleur accent ET fond teinté + texte en gras
(l'information n'est jamais portée par la couleur seule côté contenu). */
th.highlight,
td.highlight {
background: color-mix(in oklch, var(--page-accent, var(--accent)) 9%, transparent);
}
thead th.highlight {
color: var(--page-accent-text, var(--accent-text));
}
td.highlight {
color: var(--ink);
font-weight: 600;
}
@media (hover: hover) {
tbody tr:hover th,
tbody tr:hover td {
background: color-mix(in oklch, var(--ink) 5%, transparent);
}
tbody tr:hover td.highlight {
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
}
}
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
import Icon, { type IconName } from './Icon.svelte';
export interface FeatureItem {
icon: IconName;
title: string;
description: string;
}
interface Props {
items: FeatureItem[];
/** Taille minimale des colonnes de la grille (primitive .grid). */
min?: string;
}
let { items, min = '15rem' }: Props = $props();
</script>
<div class="feature-cq">
<ul class="grid" role="list" style:--grid-min={min}>
{#each items as item (item.title)}
<li class="feature-card">
<span class="feature-icon"><Icon name={item.icon} /></span>
<div class="feature-text">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
</li>
{/each}
</ul>
</div>
<style>
/* Container query : la carte passe en disposition horizontale
quand son conteneur (pas le viewport) est assez large. */
.feature-cq {
container-type: inline-size;
}
.feature-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
}
.feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.75rem;
block-size: 2.75rem;
flex-shrink: 0;
border-radius: var(--radius-1);
color: var(--page-accent-text, var(--accent-text));
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
}
h3 {
font-size: var(--text-1);
margin-block-end: var(--space-1);
}
p {
font-size: var(--text-small);
color: var(--ink-soft);
}
@container (min-width: 30rem) {
.feature-card {
flex-direction: row;
}
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts" module>
/* Icônes SVG inline (24×24, trait currentColor) — tracés statiques locaux,
aucune dépendance. Contenu interne de confiance, injecté via {@html}. */
const ICONS = {
key: '<circle cx="8" cy="15" r="4"/><path d="M11.2 12.2 20 3.5"/><path d="m16 7 3 3"/>',
server:
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/>',
'server-off':
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/><path d="m3 3 18 18"/>',
lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
zap: '<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>',
bitcoin:
'<path d="M9 4h4a3 3 0 0 1 0 6H9V4z"/><path d="M9 10h5a3 3 0 0 1 0 6H9v-6z"/><path d="M11 2v2"/><path d="M14 2v2"/><path d="M11 20v-2"/><path d="M14 20v-2"/>',
radio:
'<circle cx="12" cy="12" r="1.5"/><path d="M8.5 8.5a5 5 0 0 0 0 7"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M5.6 5.6a9 9 0 0 0 0 12.8"/><path d="M18.4 5.6a9 9 0 0 1 0 12.8"/>',
globe:
'<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a13.5 13.5 0 0 1 0 18"/><path d="M12 3a13.5 13.5 0 0 0 0 18"/>',
message:
'<path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 20l1.2-5A8.5 8.5 0 1 1 21 11.5z"/>',
external:
'<path d="M14 4h6v6"/><path d="M20 4 10 14"/><path d="M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6"/>',
github:
'<path d="M15 21v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.6 1.3a12.3 12.3 0 0 0-6.3 0C6.2 2.8 5.1 3.1 5.1 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/><path d="M9 18c-4.5 1.5-4.5-2.5-6-3"/>',
shield: '<path d="M12 2 4 5.5v5.6c0 4.9 3.4 9.3 8 10.9 4.6-1.6 8-6 8-10.9V5.5L12 2z"/>',
'user-x':
'<circle cx="9" cy="8" r="4"/><path d="M2.5 21v-1a6.5 6.5 0 0 1 11-4.7"/><path d="m17 10 5 5"/><path d="m22 10-5 5"/>',
'eye-off':
'<path d="m3 3 18 18"/><path d="M10.5 5.2A9.8 9.8 0 0 1 12 5c7 0 10 7 10 7a17.4 17.4 0 0 1-2.9 3.6"/><path d="M6.6 6.6A16.9 16.9 0 0 0 2 12s3 7 10 7a10 10 0 0 0 4.3-1"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/>',
fingerprint:
'<path d="M6.8 6.8A7 7 0 0 1 19 13c0 2.8-.4 5.4-1.1 7.5"/><path d="M5.2 10A7 7 0 0 0 5 13c0 3-.9 5.2-1.9 6.7"/><path d="M12 11a2 2 0 0 0-2 2c0 2.6-.6 5-1.7 7"/><path d="M14 13a2 2 0 0 0-2-2"/><path d="M14 13c0 2.9-.3 5.5-1 8"/>',
phone:
'<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 2 .7 2.9a2 2 0 0 1-.4 2.1L8.1 10a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.9.6 2.9.7a2 2 0 0 1 1.7 2z"/>',
users:
'<circle cx="9" cy="8" r="4"/><path d="M2 21v-1a7 7 0 0 1 14 0v1"/><path d="M16.5 4.2a4 4 0 0 1 0 7.6"/><path d="M18 14.3A7 7 0 0 1 22 20v1"/>',
chevron: '<path d="m6 9 6 6 6-6"/>'
} as const;
export type IconName = keyof typeof ICONS;
</script>
<script lang="ts">
interface Props {
name: IconName;
size?: number;
}
let { name, size = 20 }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{@html ICONS[name]}
</svg>
@@ -0,0 +1,84 @@
<script lang="ts">
import Icon, { type IconName } from './Icon.svelte';
export interface StepItem {
step: string;
icon: IconName;
title: string;
description: string;
}
interface Props {
items: StepItem[];
}
let { items }: Props = $props();
</script>
<div class="steps-cq">
<ol class="grid" role="list" style:--grid-min="17rem">
{#each items as item (item.step)}
<li class="step-card">
<span class="step-number" aria-hidden="true">{item.step}</span>
<span class="step-icon"><Icon name={item.icon} size={24} /></span>
<h3>{item.title}</h3>
<p>{item.description}</p>
</li>
{/each}
</ol>
</div>
<style>
/* Container query : rythme plus généreux quand le conteneur s'élargit. */
.steps-cq {
container-type: inline-size;
}
.step-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-3);
padding: var(--space-4);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
}
.step-number {
font-family: var(--font-mono);
font-size: var(--text-4);
font-weight: 700;
line-height: 1;
color: color-mix(in oklch, var(--page-accent, var(--accent)) 25%, transparent);
}
.step-icon {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 3rem;
block-size: 3rem;
border-radius: var(--radius-1);
color: var(--page-accent-text, var(--accent-text));
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
}
h3 {
font-size: var(--text-2);
}
p {
font-size: var(--text-small);
color: var(--ink-soft);
}
@container (min-width: 45rem) {
.step-card {
padding: var(--space-5);
}
}
</style>
+120
View File
@@ -0,0 +1,120 @@
export interface Bridge {
id: string;
name: string;
description: string;
fromProtocol: string;
toProtocol: string;
fromColor: string;
toColor: string;
status: 'Actif' | 'En développement' | 'Expérimental' | 'Planifié';
url?: string;
github?: string;
details: string;
limitations: string[];
}
export const bridges: Bridge[] = [
{
id: 'mostr',
name: 'Mostr',
description: 'Pont unidirectionnel ActivityPub → Nostr. Permet de suivre des comptes ActivityPub depuis Nostr.',
fromProtocol: 'ActivityPub',
toProtocol: 'Nostr',
fromColor: '#F72585',
toColor: '#F7931A',
status: 'Actif',
url: 'https://mostr.pub/',
github: 'https://github.com/gfanton/mostr',
details: 'Mostr relay publie les activités ActivityPub sur Nostr. Créé par Guilhem Fanton (gfanton). Les utilisateurs Nostr peuvent suivre des comptes Mastodon/Pleroma via des clés npub dérivées.',
limitations: [
'Unidirectionnel (AP → Nostr uniquement)',
'Ne supporte pas encore les médias riches',
'Dépend de la disponibilité des instances AP',
],
},
{
id: 'nostr-ap-bridge',
name: 'Nostr-AP Bridge',
description: 'Pont bidirectionnel expérimental entre Nostr et ActivityPub. Synchronise notes et réponses.',
fromProtocol: 'Nostr',
toProtocol: 'ActivityPub',
fromColor: '#F7931A',
toColor: '#F72585',
status: 'Expérimental',
github: 'https://github.com/nostr-protocol/nostr',
details: 'Implémentation expérimentale permettant la publication croisée entre les deux protocoles. Les notes Nostr apparaissent comme des posts ActivityPub et vice-versa.',
limitations: [
'En phase expérimentale précoce',
'Problèmes de formatage des contenus riches',
'Gestion des threads complexe',
],
},
{
id: 'activitypub-nostr-gateway',
name: 'AP-Nostr Gateway',
description: 'Passerelle permettant aux instances ActivityPub de communiquer avec le réseau Nostr.',
fromProtocol: 'ActivityPub',
toProtocol: 'Nostr',
fromColor: '#F72585',
toColor: '#F7931A',
status: 'En développement',
github: 'https://github.com/fediverse',
details: 'Projet communautaire visant à créer une passerelle standard entre ActivityPub et Nostr. Supporte la conversion de formats d\'activités.',
limitations: [
'Non standardisé',
'Performance limitée',
'Nécessite un relay dédié',
],
},
{
id: 'fedify-nostr',
name: 'Fedify + Nostr',
description: 'Le framework Fedify explore le support multi-protocoles incluant Nostr.',
fromProtocol: 'ActivityPub',
toProtocol: 'Nostr',
fromColor: '#F72585',
toColor: '#F7931A',
status: 'Planifié',
url: 'https://fedify.dev/',
github: 'https://github.com/dahlia/fedify',
details: 'Fedify, le framework TypeScript pour ActivityPub, envisage d\'ajouter le support Nostr pour permettre aux développeurs de construire des applications multi-protocoles.',
limitations: [
'En phase de planification',
'Pas de release date annoncée',
'Complexité technique élevée',
],
},
{
id: 'soapbox-nostr',
name: 'Soapbox Nostr Bridge',
description: 'Pont intégré à Soapbox (frontend Pleroma) pour la cross-publication Nostr.',
fromProtocol: 'ActivityPub',
toProtocol: 'Nostr',
fromColor: '#F72585',
toColor: '#F7931A',
status: 'En développement',
url: 'https://soapbox.pub/',
details: 'Soapbox, le frontend alternatif pour Pleroma, développe un module de bridge Nostr permettant aux utilisateurs de cross-publier leurs contenus.',
limitations: [
'Spécifique à Soapbox/Pleroma',
'Configuration requise par l\'administrateur',
'Support partiel des formats',
],
},
];
export const bridgeProtocolOrder = [
{ name: 'ActivityPub', color: '#F72585' },
{ name: 'Nostr', color: '#F7931A' },
{ name: 'XMPP', color: '#06D6A0' },
{ name: 'Matrix', color: '#4361EE' },
{ name: 'AT Protocol', color: '#4CC9F0' },
{ name: 'SimpleX', color: '#2EC4B6' },
];
export const bridgeStatusInfo: Record<string, { label: string; color: string }> = {
Actif: { label: '✅ Actif', color: '#2EC4B6' },
'En développement': { label: '🔧 En développement', color: '#F9C74F' },
Expérimental: { label: '🧪 Expérimental', color: '#F7931A' },
Planifié: { label: '📋 Planifié', color: '#4CC9F0' },
};
@@ -0,0 +1,100 @@
[
{
"id": "social",
"name": "Réseaux sociaux",
"icon": "Users",
"color": "#4CC9F0",
"description": "Plateformes sociales fédérées"
},
{
"id": "microblog",
"name": "Microblogging",
"icon": "MessageSquare",
"color": "#F9C74F",
"description": "Micro-blogs et status updates"
},
{
"id": "photo",
"name": "Photographie",
"icon": "Camera",
"color": "#F8961E",
"description": "Partage de photos et images"
},
{
"id": "video",
"name": "Vidéo",
"icon": "Video",
"color": "#F72585",
"description": "Streaming et partage vidéo"
},
{
"id": "audio",
"name": "Audio & Podcast",
"icon": "Headphones",
"color": "#B5179E",
"description": "Musique, podcasts et audio"
},
{
"id": "blog",
"name": "Blogs",
"icon": "FileText",
"color": "#7209B7",
"description": "Plateformes de blogging"
},
{
"id": "forum",
"name": "Forums",
"icon": "MessageCircle",
"color": "#3A0CA3",
"description": "Forums et agrégateurs"
},
{
"id": "events",
"name": "Événements",
"icon": "Calendar",
"color": "#4361EE",
"description": "Gestion d'événements"
},
{
"id": "dev",
"name": "Développement",
"icon": "Code",
"color": "#4CC9F0",
"description": "Forge et développement logiciel"
},
{
"id": "collab",
"name": "Collaboration",
"icon": "FolderOpen",
"color": "#2EC4B6",
"description": "Outils collaboratifs et cloud"
},
{
"id": "books",
"name": "Livres",
"icon": "BookOpen",
"color": "#E71D36",
"description": "Lecture et partage de livres"
},
{
"id": "wiki",
"name": "Wiki",
"icon": "Globe",
"color": "#662E9B",
"description": "Encyclopédies collaboratives"
},
{
"id": "messaging",
"name": "Messagerie",
"icon": "Send",
"color": "#06D6A0",
"description": "Messagerie instantanée privée et sécurisée"
},
{
"id": "nostr",
"name": "Nostr",
"icon": "Zap",
"color": "#F7931A",
"description": "Écosystème Nostr — protocole voisin"
}
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import {
categories,
categoryById,
networkData,
protocols,
softwares,
stats
} from './index';
import { timelineEvents } from './timeline';
import { bridges } from './bridges';
describe('données de lAtlas', () => {
it('référence 105 logiciels, 12 protocoles, 14 catégories', () => {
expect(softwares).toHaveLength(105);
expect(protocols).toHaveLength(12);
expect(categories).toHaveLength(14);
});
it('chaque logiciel pointe vers une catégorie existante', () => {
for (const software of softwares) {
expect(categoryById.has(software.category)).toBe(true);
}
});
it('les compteurs dérivés sont cohérents avec les métadonnées', () => {
expect(stats.totalSoftwares).toBe(105);
expect(stats.activeSoftwares).toBe(93);
});
it('le graphe réseau est déterministe et couvre les 105 logiciels', () => {
expect(networkData.nodes).toHaveLength(105);
expect(networkData.links.length).toBeGreaterThan(0);
const nodeIds = new Set(networkData.nodes.map((n) => n.id));
for (const link of networkData.links) {
const source = typeof link.source === 'string' ? link.source : link.source.id;
const target = typeof link.target === 'string' ? link.target : link.target.id;
expect(nodeIds.has(source)).toBe(true);
expect(nodeIds.has(target)).toBe(true);
}
});
it('la chronologie couvre 2008 → 2026 dans lordre croissant', () => {
expect(timelineEvents.length).toBeGreaterThanOrEqual(30);
expect(timelineEvents[0].year).toBe(2008);
for (let i = 1; i < timelineEvents.length; i++) {
expect(timelineEvents[i].year).toBeGreaterThanOrEqual(timelineEvents[i - 1].year);
}
});
it('les ponts Nostr ↔ ActivityPub sont présents', () => {
expect(bridges.length).toBeGreaterThanOrEqual(5);
});
});
+52
View File
@@ -0,0 +1,52 @@
/* Point d'entrée des données de l'Atlas — modules JSON typés.
Source de vérité : /public/data du projet v4 (réutilisés tels quels). */
import softwareJson from './software.json';
import protocolsJson from './protocols.json';
import categoriesJson from './categories.json';
import networkJson from './network.json';
import metadataJson from './metadata.json';
import type { Category, NetworkData, Protocol, Software } from './types';
export const softwares = softwareJson as Software[];
export const protocols = protocolsJson as Protocol[];
export const categories = categoriesJson as Category[];
export const networkData = networkJson as NetworkData;
export const metadata = metadataJson;
export const categoryById: ReadonlyMap<string, Category> = new Map(categories.map((c) => [c.id, c]));
export const softwareById: ReadonlyMap<string, Software> = new Map(softwares.map((s) => [s.id, s]));
/** Ordre de tri des états d'activité (Actif d'abord). */
export const ETAT_ORDER: readonly Software['etat'][] = [
'Actif',
'Maintenance',
'Experimental',
'Historique'
];
export const ETATS: readonly Software['etat'][] = ETAT_ORDER;
export const COMPATIBILITES_AP: readonly Software['compatibiliteAP'][] = [
'Pleine',
'Partielle',
'Extension',
'Aucune',
'En développement'
];
export function softwaresByCategory(categoryId: string): Software[] {
return softwares.filter((s) => s.category === categoryId);
}
export const stats = {
totalSoftwares: softwares.length,
totalProtocols: protocols.length,
totalCategories: categories.length,
activeSoftwares: softwares.filter((s) => s.etat === 'Actif').length,
networkLinks: networkData.links.length,
yearsOfHistory: new Date().getFullYear() - 2008
} as const;
export type { Category, NetworkData, NetworkLink, NetworkNode, Protocol, Software } from './types';
@@ -0,0 +1,27 @@
{
"title": "Le Grand Atlas du Fédiverse",
"version": "4.0",
"year": 2026,
"language": "fr",
"licence": "CC BY-SA 4.0",
"lastUpdated": "2026-07-12",
"statistics": {
"totalSoftwares": 105,
"totalProtocols": 12,
"totalCategories": 14,
"activeSoftwares": 93,
"maintenanceSoftwares": 4,
"experimentalSoftwares": 5,
"historicalSoftwares": 3,
"fullAPCompatibility": 45,
"partialAPCompatibility": 4,
"extensionAPCompatibility": 9
},
"attribution": {
"inspiredBy": "Per Axbom (axbom.com/fediverse)",
"originalVersion": "3.0 (January 2023)",
"maintainers": [
"Community"
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
[
{
"id": "activitypub",
"name": "ActivityPub",
"description": "Protocole de réseautage social décentralisé recommandé par le W3C. Le standard principal du Fédiverse.",
"type": "fediverse",
"statut": "Actif",
"site": "https://activitypub.rocks/",
"rfc": "W3C Recommendation"
},
{
"id": "activitystreams",
"name": "ActivityStreams 2.0",
"description": "Format de données pour décrire les activités sociales. Base sur laquelle repose ActivityPub.",
"type": "fediverse",
"statut": "Actif",
"rfc": "W3C Recommendation"
},
{
"id": "webfinger",
"name": "WebFinger",
"description": "Protocole de découverte d'informations sur les ressources identifiées par des URI web.",
"type": "fediverse",
"statut": "Actif",
"rfc": "RFC 7033"
},
{
"id": "httpsignatures",
"name": "HTTP Signatures",
"description": "Mécanisme de signature des requêtes HTTP pour l'authentification entre instances.",
"type": "fediverse",
"statut": "Actif",
"rfc": "IETF Draft"
},
{
"id": "nodeinfo",
"name": "NodeInfo",
"description": "Schéma de métadonnées pour décrire les instances fédérées et leurs capacités.",
"type": "fediverse",
"statut": "Actif",
"rfc": "nodeinfo.diaspora.software"
},
{
"id": "zot",
"name": "Zot / Nomad",
"description": "Protocole de communications décentralisées avec prise en charge avancée de la privacy et du nomadisme d'identité.",
"type": "connexe",
"statut": "Actif",
"site": "https://zotlabs.org/"
},
{
"id": "diaspora",
"name": "Diaspora* Protocol",
"description": "Protocole historique du réseau Diaspora*, précurseur du Fédiverse moderne.",
"type": "connexe",
"statut": "Historique"
},
{
"id": "ostatus",
"name": "OStatus",
"description": "Suite de protocoles (Atom, Salmon, WebSub) utilisée par les premières plateformes fédérées.",
"type": "connexe",
"statut": "Historique"
},
{
"id": "matrix",
"name": "Matrix Protocol",
"description": "Protocole ouvert pour la communication temps réel (chat, VoIP). Interopère avec ActivityPub.",
"type": "connexe",
"statut": "Actif",
"site": "https://matrix.org/"
},
{
"id": "xmpp",
"name": "XMPP",
"description": "Extensible Messaging and Presence Protocol. Standard historique de messagerie instantanée.",
"type": "connexe",
"statut": "Actif",
"rfc": "RFC 6120"
},
{
"id": "atproto",
"name": "AT Protocol",
"description": "Protocole d'identité décentralisé utilisé par Bluesky. Écosystème voisin mais distinct du Fédiverse.",
"type": "voisin",
"statut": "En développement",
"site": "https://atproto.com/"
},
{
"id": "nostr",
"name": "Nostr",
"description": "Protocole simple et ouvert pour créer des réseaux sociaux résistants à la censure.",
"type": "voisin",
"statut": "En développement",
"site": "https://nostr.com/"
}
]
File diff suppressed because it is too large Load Diff
+418
View File
@@ -0,0 +1,418 @@
export interface TimelineEvent {
year: number;
month?: number;
title: string;
description: string;
category: 'foundation' | 'protocol' | 'software' | 'milestone' | 'ecosystem';
softwareId?: string;
protocolId?: string;
color: string;
}
export const timelineEvents: TimelineEvent[] = [
// 2008
{
year: 2008,
title: 'StatusNet / Identi.ca',
description: "Evan Prodromou lance Identi.ca basé sur StatusNet. Première plateforme de microblogging décentralisée utilisant le protocole OStatus.",
category: 'foundation',
color: '#6C757D',
},
// 2010
{
year: 2010,
title: 'GNU social',
description: "Fork de StatusNet. Devient le pionnier du réseau social fédéré avec le protocole OStatus.",
category: 'software',
softwareId: 'gnusocial',
color: '#4CC9F0',
},
{
year: 2010,
title: 'Diaspora*',
description: "Lancement de Diaspora* après une campagne Kickstarter. Réseau social décentralisé avec son propre protocole. Précurseur du Fédiverse moderne.",
category: 'software',
softwareId: 'diaspora-soft',
color: '#4CC9F0',
},
{
year: 2010,
title: 'ownCloud',
description: "Lancement de la plateforme de cloud auto-hébergeable. Posera plus tard les bases de Nextcloud.",
category: 'software',
softwareId: 'owncloud',
color: '#2EC4B6',
},
// 2011
{
year: 2011,
title: 'Friendica',
description: "Plateforme sociale fédérée avec support multi-protocoles (OStatus, Diaspora*, plus tard ActivityPub).",
category: 'software',
softwareId: 'friendica',
color: '#4CC9F0',
},
// 2012
{
year: 2012,
title: 'Hubzilla',
description: "Plateforme de communications décentralisées avec contrôle d'accès granulaire et nomadisme d'identité via le protocole Zot.",
category: 'software',
softwareId: 'hubzilla',
color: '#4CC9F0',
},
{
year: 2012,
title: 'W3C Social Web Working Group',
description: "Création du groupe de travail W3C sur le web social. Aboutira à la standardisation d'ActivityPub.",
category: 'foundation',
color: '#F72585',
},
// 2014
{
year: 2014,
title: 'Misskey',
description: "Lancement au Japon de Misskey, une plateforme sociale fédérée avec des fonctionnalités créatives uniques.",
category: 'software',
softwareId: 'misskey',
color: '#4CC9F0',
},
// 2015
{
year: 2015,
title: 'PeerTube',
description: "Framasoft lance PeerTube, une plateforme vidéo fédérée et décentralisée. Alternative libre à YouTube.",
category: 'software',
softwareId: 'peertube',
color: '#F72585',
},
{
year: 2015,
title: 'Mastodon',
description: "Eugen Rochko lance Mastodon. Le début de l'explosion du Fédiverse moderne. Utilise OStatus, migrera plus tard vers ActivityPub.",
category: 'software',
softwareId: 'mastodon',
color: '#4CC9F0',
},
// 2016
{
year: 2016,
title: 'Pleroma',
description: "Serveur ActivityPub léger et configurable. Alternative rapide et ressource-efficiente à Mastodon.",
category: 'software',
softwareId: 'pleroma',
color: '#4CC9F0',
},
{
year: 2016,
title: 'Matrix Protocol',
description: "Standardisation du protocole Matrix pour la communication temps réel décentralisée.",
category: 'protocol',
color: '#4361EE',
},
{
year: 2016,
title: 'Nextcloud',
description: "Fork de ownCloud devenu la référence du cloud auto-hébergeable. Ajoutera plus tard le support ActivityPub.",
category: 'software',
softwareId: 'nextcloud',
color: '#2EC4B6',
},
// 2017
{
year: 2017,
title: 'W3C ActivityPub — Candidate Recommendation',
description: "ActivityPub atteint le statut de Candidate Recommendation au W3C. Le Fédiverse a désormais son standard.",
category: 'protocol',
color: '#F72585',
},
{
year: 2017,
title: 'PixelFed',
description: "Lancement de PixelFed, plateforme de partage de photos fédérée. Alternative à Instagram.",
category: 'software',
softwareId: 'pixelfed',
color: '#F8961E',
},
{
year: 2017,
title: 'WriteFreely',
description: "Plateforme de blogging minimaliste et fédérée axée sur l'écriture pure.",
category: 'software',
softwareId: 'writefreely',
color: '#7209B7',
},
// 2018
{
year: 2018,
title: 'ActivityPub — W3C Recommendation',
description: "ActivityPub devient une recommandation officielle du W3C. Le standard est désormais finalisé.",
category: 'milestone',
color: '#F72585',
},
{
year: 2018,
title: 'BookWyrm',
description: "Réseau social de lecture fédéré. Alternative libre à Goodreads.",
category: 'software',
softwareId: 'bookwyrm',
color: '#E71D36',
},
{
year: 2018,
title: 'Plume',
description: "Moteur de blog fédéré axé sur la collaboration et les publications longues.",
category: 'software',
softwareId: 'plume',
color: '#7209B7',
},
{
year: 2018,
title: 'Funkwhale',
description: "Plateforme musicale fédérée pour écouter, partager et découvrir de la musique.",
category: 'software',
softwareId: 'funkwhale',
color: '#B5179E',
},
{
year: 2018,
title: 'microblog.pub',
description: "Serveur ActivityPub minimaliste et auto-hébergeable pour le microblogging.",
category: 'software',
softwareId: 'microblogpub',
color: '#F9C74F',
},
// 2019
{
year: 2019,
title: 'Lemmy',
description: "Agrégateur de liens et forum fédéré. Alternative à Reddit.",
category: 'software',
softwareId: 'lemmy',
color: '#3A0CA3',
},
{
year: 2019,
title: 'Mobilizon',
description: "Plateforme d'événements fédérée par Framasoft. Alternative à Facebook Events.",
category: 'software',
softwareId: 'mobilizon',
color: '#4361EE',
},
{
year: 2019,
title: 'WordPress + ActivityPub',
description: "Automattic publie le plugin ActivityPub pour WordPress. Le plus grand CMS du monde rejoint le Fédiverse.",
category: 'software',
softwareId: 'wordpress-ap',
color: '#7209B7',
},
{
year: 2019,
title: 'Nostr Protocol',
description: "Publication du protocole Nostr par fiatjaf. Une approche radicalement différente : pas de serveurs, pas d'instances, juste des relays et des clés.",
category: 'protocol',
color: '#F7931A',
},
// 2020
{
year: 2020,
title: 'Owncast',
description: "Serveur de streaming live auto-hébergeable. Alternative à Twitch.",
category: 'software',
softwareId: 'owncast',
color: '#F72585',
},
{
year: 2020,
title: 'Bonfire',
description: "Réseau social modulaire axé sur la gouvernance communautaire et la coordination sociale.",
category: 'software',
softwareId: 'bonfire',
color: '#4CC9F0',
},
{
year: 2020,
title: 'Castopod',
description: "Plateforme de podcasting fédérée avec ActivityPub. Alternative à Spotify/Apple Podcasts.",
category: 'software',
softwareId: 'castopod',
color: '#B5179E',
},
{
year: 2020,
title: 'SimpleX Chat',
description: "Lancement de SimpleX Chat, une messagerie décentralisée de nouvelle génération sans identifiants utilisateur.",
category: 'software',
softwareId: 'simplexchat',
color: '#06D6A0',
},
// 2021
{
year: 2021,
title: 'Twitter → Mastodon',
description: "Vague massive de migrations de Twitter vers Mastodon après l'acquisition par Elon Musk. Le Fédiverse passe de 2M à 10M+ d'utilisateurs.",
category: 'milestone',
color: '#F72585',
},
{
year: 2021,
title: 'GoToSocial',
description: "Serveur ActivityPub léger écrit en Go. Approche minimaliste et efficace pour auto-héberger son instance.",
category: 'software',
softwareId: 'gotosocial',
color: '#4CC9F0',
},
// 2022
{
year: 2022,
title: 'Akkoma',
description: "Fork de Pleroma avec des fonctionnalités enrichies et une personnalisation poussée.",
category: 'software',
softwareId: 'akkoma',
color: '#4CC9F0',
},
{
year: 2022,
title: 'Mbin',
description: "Successeur de kbin, agrégateur de liens fédéré avec améliorations significatives.",
category: 'software',
softwareId: 'mbin',
color: '#3A0CA3',
},
{
year: 2022,
title: 'Gancio',
description: "Agrégateur d'événements fédéré auto-hébergeable.",
category: 'software',
softwareId: 'gancio',
color: '#4361EE',
},
{
year: 2022,
title: 'Forgejo',
description: "Forge logicielle libre forkée de Gitea. Le GitHub libre se fédère.",
category: 'software',
softwareId: 'forgejo',
color: '#4CC9F0',
},
{
year: 2022,
title: 'Mitra',
description: "Serveur ActivityPub en Rust avec support des monnaies complémentaires et des économies locales.",
category: 'software',
softwareId: 'mitra',
color: '#4CC9F0',
},
{
year: 2022,
title: 'Streams',
description: "Fork de Hubzilla axé sur la privacy et le contrôle granulaire des permissions.",
category: 'software',
softwareId: 'streams',
color: '#4CC9F0',
},
// 2023
{
year: 2023,
title: 'Threads → ActivityPub',
description: "Meta annonce que Threads intégrera ActivityPub. Le géant des réseaux sociaux rejoint le protocole fédéré.",
category: 'milestone',
color: '#F72585',
},
{
year: 2023,
title: 'Sharkey',
description: "Fork de Misskey avec améliorations de performance et nouvelles fonctionnalités.",
category: 'software',
softwareId: 'sharkey',
color: '#4CC9F0',
},
{
year: 2023,
title: 'Iceshrimp',
description: "Fork de Misskey optimisé pour la performance et la stabilité.",
category: 'software',
softwareId: 'iceshrimp',
color: '#4CC9F0',
},
{
year: 2023,
title: 'NodeBB + ActivityPub',
description: "NodeBB ajoute le support ActivityPub. Les forums classiques rejoignent le Fédiverse.",
category: 'software',
softwareId: 'nodebb-ap',
color: '#3A0CA3',
},
{
year: 2023,
title: 'Ghost + ActivityPub',
description: "Ghost intègre ActivityPub. Le CMS de blogging professionnel devient fédéré.",
category: 'software',
softwareId: 'ghost',
color: '#7209B7',
},
{
year: 2023,
title: 'Bluesky ouvre au public',
description: "Bluesky (AT Protocol) ouvre ses portes. L'écosystème des réseaux décentralisés gagne en visibilité.",
category: 'ecosystem',
color: '#4CC9F0',
},
// 2024
{
year: 2024,
title: 'Loops',
description: "Plateforme de vidéos courtes fédérée par Mozilla. Alternative à TikTok/Instagram Reels.",
category: 'software',
softwareId: 'loops',
color: '#F72585',
},
{
year: 2024,
title: 'Fedify',
description: "Framework TypeScript/Deno pour construire des serveurs ActivityPub. Simplifie le développement fédéré.",
category: 'software',
softwareId: 'fedify',
color: '#4CC9F0',
},
{
year: 2024,
title: 'Vernissage',
description: "Application de galerie photo fédérée pour le Fédiverse.",
category: 'software',
softwareId: 'vernissage',
color: '#F8961E',
},
{
year: 2024,
title: 'Nostr — Croissance explosive',
description: "Nostr atteint des millions d'utilisateurs. Damus, Amethyst, Primal et d'autres clients dominent. Bitcoin Lightning s'intègre pour les zaps.",
category: 'ecosystem',
color: '#F7931A',
},
// 2025
{
year: 2025,
title: 'Interconnexion Nostr ↔ ActivityPub',
description: "Les premiers ponts bidirectionnels entre Nostr et ActivityPub voient le jour. Les deux écosystèmes communiquent.",
category: 'milestone',
color: '#F7931A',
},
{
year: 2025,
title: 'SimpleX Chat — v6.0',
description: "SimpleX atteint la v6.0 avec des groupes chiffrés de grande taille et des appels vidéo. Alternative mature à Signal et WhatsApp.",
category: 'software',
softwareId: 'simplexchat',
color: '#06D6A0',
},
// 2026
{
year: 2026,
title: 'Le Grand Atlas du Fédiverse v4.0',
description: "Publication de cette cartographie complète recensant 55+ logiciels, 12 protocoles et 18 ans d'histoire du réseau fédéré.",
category: 'milestone',
color: '#F72585',
},
];
+66
View File
@@ -0,0 +1,66 @@
export interface Software {
id: string;
name: string;
description: string;
category: string;
protocols: string[];
licence: string;
language: string;
site: string;
github: string;
etat: 'Actif' | 'Maintenance' | 'Experimental' | 'Historique';
compatibiliteAP: 'Pleine' | 'Partielle' | 'Extension' | 'Aucune' | 'En développement';
version?: string;
createdYear?: number;
}
export interface Protocol {
id: string;
name: string;
description: string;
type: 'fediverse' | 'connexe' | 'voisin';
statut: 'Actif' | 'Historique' | 'En développement';
site?: string;
rfc?: string;
}
export interface Category {
id: string;
name: string;
icon: string;
color: string;
description: string;
}
export interface NetworkNode {
id: string;
group: number;
val: number;
description: string;
category: string;
color: string;
etat: string;
compatibiliteAP: string;
licence: string;
x?: number;
y?: number;
z?: number;
vx?: number;
vy?: number;
vz?: number;
fx?: number | null;
fy?: number | null;
fz?: number | null;
index?: number;
}
export interface NetworkLink {
source: string | NetworkNode;
target: string | NetworkNode;
type: 'strong' | 'weak';
}
export interface NetworkData {
nodes: NetworkNode[];
links: NetworkLink[];
}
+69
View File
@@ -0,0 +1,69 @@
/* Dictionnaire français — locale de référence.
Structure de clés partagée avec gcf.ts (fallback automatique vers fr). */
export const fr = {
a11y: {
skipToContent: 'Aller au contenu principal'
},
nav: {
map: 'Carte',
protocols: 'Protocoles',
timeline: 'Chronologie',
catalog: 'Logiciels',
nostr: 'Nostr',
simplex: 'SimpleX',
main: 'Navigation principale',
openMenu: 'Ouvrir le menu',
closeMenu: 'Fermer le menu'
},
header: {
home: "Le Grand Atlas du Fédiverse — retour à l'accueil",
backToAtlas: "Retour à l'Atlas",
install: "Installer l'application",
installed: 'Application installée'
},
offline: {
indicator: 'Hors connexion — vous naviguez sur la copie locale'
},
common: {
close: 'Fermer',
loading: 'Chargement…',
search: 'Rechercher',
clear: 'Effacer',
resetFilters: 'Réinitialiser les filtres',
emptyResults: 'Aucun résultat pour ces critères',
officialSite: 'Site officiel',
sourceCode: 'Code source',
backToTop: 'Retour en haut de page'
},
catalog: {
upToDate: 'Vous êtes à jour',
upToDateCreole: 'ou pa manké ayen',
results: '{count} résultat(s)'
},
shortcuts: {
title: 'Raccourcis clavier',
open: 'Afficher les raccourcis clavier',
search: 'Aller à la recherche du catalogue',
help: 'Afficher cette aide',
next: 'Logiciel suivant dans le catalogue',
prev: 'Logiciel précédent dans le catalogue',
close: 'Fermer la fenêtre'
},
footer: {
baseline:
'Une cartographie interactive et communautaire du réseau fédéré décentralisé. Maintenue à jour par et pour la communauté francophone.',
madeIn: 'Fait avec ❤ en Guadeloupe',
project: 'Projet',
about: 'À propos',
license: 'Licence CC BY-SA',
source: 'Code source',
api: 'Données (JSON)',
resources: 'Ressources',
community: 'Communauté',
rights: 'Le Grand Atlas du Fédiverse © 2026 — Sous licence CC BY-SA 4.0',
credits:
'Inspiré par Per Axbom (axbom.com/fediverse) · Données mises à jour régulièrement par la communauté',
localeLabel: 'Langue de linterface'
}
};
+75
View File
@@ -0,0 +1,75 @@
/* Dictionnaire créole guadeloupéen (gcf).
Traduction progressive : les clés absentes retombent sur le français. */
import type { fr } from './fr';
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
export const gcf: DeepPartial<typeof fr> = {
a11y: {
skipToContent: 'Alé o kontni prensipal'
},
nav: {
map: 'Kat',
protocols: 'Pwotokòl',
timeline: 'Kronoloji',
catalog: 'Lojisyèl',
nostr: 'Nostr',
simplex: 'SimpleX',
main: 'Navigasyon prensipal',
openMenu: 'Ouvri meni an',
closeMenu: 'Fèmé meni an'
},
header: {
home: 'Gran Atlas Fédiverse — tounen aksèy',
backToAtlas: 'Tounen an Atlas la',
install: 'Enstalé aplikasyon an',
installed: 'Aplikasyon enstalé'
},
offline: {
indicator: 'San kònèksyon — w ap gadé kopi lokal la'
},
common: {
close: 'Fèmé',
loading: 'Sa ap chajé…',
search: 'Chèchè',
clear: 'Éfasé',
resetFilters: 'Rémèt filtr yo a zéwo',
emptyResults: 'Pa ni pyès rézilta pou kritè sa-yo',
officialSite: 'Sit ofisyèl',
sourceCode: 'Kòd sous',
backToTop: 'Tounen anlè paj la'
},
catalog: {
upToDate: 'Ou pa manké ayen',
upToDateCreole: 'vous êtes à jour',
results: '{count} rézilta'
},
shortcuts: {
title: 'Rakoursi klavyé',
open: 'Afiché rakoursi klavyé yo',
search: 'Alé ba rachech katalòg la',
help: 'Afiché èd sa a',
next: 'Lojisyèl swivan an katalòg la',
prev: 'Lojisyèl avan an katalòg la',
close: 'Fèmé fenèt la'
},
footer: {
baseline:
'Yon kat entèaktif é kominotèr di rézo fédéré désantralizé a. Kominoté frankofonn ka tjouwen li ajou.',
madeIn: 'Fè ké lanmou an Gwadloup',
project: 'Pwojè',
about: 'Konsènan',
license: 'Lisans CC BY-SA',
source: 'Kòd sous',
api: 'Done (JSON)',
resources: 'Résous',
community: 'Kominoté',
rights: 'Gran Atlas Fédiverse © 2026 — Anba lisans CC BY-SA 4.0',
credits:
'Enspiré pa Per Axbom (axbom.com/fediverse) · Done yo ka mèt ajou souvan pa kominoté a',
localeLabel: 'Lang entèfas la'
}
};
@@ -0,0 +1,60 @@
/* i18n réactif (runes Svelte 5) — préparation bilinguisme FR / créole.
Usage dans un composant : `import { t } from '$lib/i18n/i18n.svelte';`
puis `{t('nav.map')}` dans le template (réactif au changement de locale). */
import { browser } from '$app/environment';
import { fr } from './fr';
import { gcf } from './gcf';
export const LOCALES = [
{ id: 'fr', label: 'Français' },
{ id: 'gcf', label: 'Kréyòl' }
] as const;
export type Locale = (typeof LOCALES)[number]['id'];
const STORAGE_KEY = 'atlas-locale';
const dicts: Record<Locale, unknown> = { fr, gcf };
let locale = $state<Locale>('fr');
function lookup(dict: unknown, path: string): string | undefined {
let node: unknown = dict;
for (const part of path.split('.')) {
if (node === null || typeof node !== 'object') return undefined;
node = (node as Record<string, unknown>)[part];
}
return typeof node === 'string' ? node : undefined;
}
/** Traduit une clé (ex. 'nav.map'). Retombe sur le français si absente.
Supporte l'interpolation `{name}` via params. */
export function t(key: string, params?: Record<string, string | number>): string {
// La lecture de `locale` rend l'appel réactif dans les templates Svelte 5.
const raw = lookup(dicts[locale], key) ?? lookup(fr, key) ?? key;
if (!params) return raw;
return raw.replaceAll(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`));
}
export function getLocale(): Locale {
return locale;
}
export function setLocale(next: Locale): void {
locale = next;
if (browser) {
localStorage.setItem(STORAGE_KEY, next);
document.documentElement.lang = next;
}
}
/** À appeler une fois côté client (layout racine). */
export function initLocale(): void {
if (!browser) return;
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'fr' || saved === 'gcf') {
locale = saved;
}
document.documentElement.lang = locale;
}
+8
View File
@@ -0,0 +1,8 @@
/** Garde commune : ne pas intercepter les raccourcis pendant la saisie. */
export function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
);
}
+51
View File
@@ -0,0 +1,51 @@
/* État PWA partagé (runes) — capture beforeinstallprompt, détection installée. */
import { browser } from '$app/environment';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
let deferredPrompt = $state<BeforeInstallPromptEvent | null>(null);
let installed = $state(false);
let initialized = false;
/** À appeler une fois côté client (layout racine). */
export function initPwa(): void {
if (!browser || initialized) return;
initialized = true;
if (window.matchMedia('(display-mode: standalone)').matches) {
installed = true;
}
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredPrompt = event as BeforeInstallPromptEvent;
});
window.addEventListener('appinstalled', () => {
installed = true;
deferredPrompt = null;
});
}
export function canInstall(): boolean {
return deferredPrompt !== null;
}
export function isInstalled(): boolean {
return installed;
}
/** Déclenche le prompt d'installation natif. Retourne true si accepté. */
export async function promptInstall(): Promise<boolean> {
if (!deferredPrompt) return false;
const promptEvent = deferredPrompt;
deferredPrompt = null;
await promptEvent.prompt();
const { outcome } = await promptEvent.userChoice;
return outcome === 'accepted';
}
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import ShortcutsHelp from '$lib/components/ShortcutsHelp.svelte';
import { initLocale, t } from '$lib/i18n/i18n.svelte';
import { initPwa } from '$lib/pwa.svelte';
let { children } = $props();
onMount(() => {
// Marqueur de démarrage pour le script de diagnostic de app.html
document.documentElement.dataset.js = 'ok';
initLocale();
initPwa();
});
</script>
<a class="skip-link" href="#main">{t('a11y.skipToContent')}</a>
<Header />
<main id="main" tabindex="-1">
{@render children()}
</main>
<Footer />
<OfflineIndicator />
<ShortcutsHelp />
+6
View File
@@ -0,0 +1,6 @@
export const prerender = true;
// Hébergement statique (nginx YunoHost) : chaque page est générée dans son
// dossier (nostr/index.html) — structure la plus sûre pour du statique,
// sans dépendre de règles de réécriture.
export const trailingSlash = 'always';
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import HeroSection from '$lib/components/HeroSection.svelte';
import NetworkGraph from '$lib/components/NetworkGraph.svelte';
import ProtocolSection from '$lib/components/ProtocolSection.svelte';
import TimelineSection from '$lib/components/TimelineSection.svelte';
import SoftwareCatalog from '$lib/components/SoftwareCatalog.svelte';
</script>
<svelte:head>
<title>Le Grand Atlas du Fédiverse — 2026</title>
</svelte:head>
<HeroSection />
<NetworkGraph />
<ProtocolSection />
<TimelineSection />
<SoftwareCatalog />
@@ -0,0 +1,702 @@
<script lang="ts">
import { softwares } from '$lib/data';
import { t } from '$lib/i18n/i18n.svelte';
import Icon from '$lib/components/pages/Icon.svelte';
import type { IconName } from '$lib/components/pages/Icon.svelte';
import FeatureGrid from '$lib/components/pages/FeatureGrid.svelte';
import type { FeatureItem } from '$lib/components/pages/FeatureGrid.svelte';
import StepsGrid from '$lib/components/pages/StepsGrid.svelte';
import type { StepItem } from '$lib/components/pages/StepsGrid.svelte';
import CompareTable from '$lib/components/pages/CompareTable.svelte';
import type { CompareRow } from '$lib/components/pages/CompareTable.svelte';
const clients = softwares.filter((s) => s.category === 'nostr');
let openId = $state<string | null>(null);
function toggle(id: string): void {
openId = openId === id ? null : id;
}
const steps: StepItem[] = [
{
step: '01',
icon: 'key',
title: 'Générez vos clés',
description:
"Votre client Nostr crée une paire de clés (npub + nsec). Pas d'inscription, pas d'email. Vos clés SONT votre identité."
},
{
step: '02',
icon: 'server',
title: 'Connectez des relays',
description:
'Choisissez des relays publics ou hébergez le vôtre. Vos messages sont publiés sur tous les relays que vous sélectionnez.'
},
{
step: '03',
icon: 'message',
title: 'Communiquez',
description:
"Publiez des notes, suivez d'autres utilisateurs, envoyez des messages privés chiffrés. Tout fonctionne sans serveur central."
}
];
const features: FeatureItem[] = [
{
icon: 'key',
title: 'Clés cryptographiques',
description:
"Pas de compte, pas d'email, pas de mot de passe. Votre identité est une paire de clés (npub/nsec). Vous la contrôlez entièrement."
},
{
icon: 'server',
title: "Relays, pas d'instances",
description:
'Pas de serveurs à administrer. Les relays sont de simples relais de messages. Vous en choisissez plusieurs, vous pouvez en changer à tout moment.'
},
{
icon: 'lock',
title: 'Chiffrement de bout en bout',
description:
'Les messages DM sont chiffrés côté client avec les clés publiques du destinataire. Même les relays ne peuvent pas lire vos messages.'
},
{
icon: 'bitcoin',
title: 'Lightning Zaps',
description:
'Intégration native avec Bitcoin Lightning Network. Envoyez des satoshis (zaps) en récompense de contenus. Économie circulaire intégrée.'
},
{
icon: 'radio',
title: 'Censure-résistant',
description:
'Pas de point central de contrôle. Vos messages existent sur de multiples relays simultanément. Impossible de vous bannir du réseau.'
},
{
icon: 'globe',
title: 'Interopérabilité totale',
description:
'Tous les clients Nostr peuvent lire et publier sur tous les relays. Pas de fragmentation comme entre instances ActivityPub.'
}
];
const comparison: CompareRow[] = [
{ aspect: 'Architecture', values: ['Instances fédérées (serveurs)', 'Relays décentralisés (pas de serveurs)'] },
{ aspect: 'Identité', values: ['@utilisateur@instance.social', 'Clé publique (npub1...)'] },
{ aspect: 'Chiffrement', values: ['TLS + HTTP Signatures (serveur)', 'Chiffrement client-side (NIP-04)'] },
{ aspect: 'Résilience', values: ['Instance = point de défaillance', 'Multi-relays, pas de SPOF'] },
{ aspect: 'Découverte', values: ['WebFinger + annuaires', 'Relays + moteurs de recherche'] },
{ aspect: 'Médias', values: ["Stockage sur l'instance", 'Blossom / NIP-96 (décentralisé)'] },
{ aspect: 'Monétisation', values: ['Donations, sponsoring', 'Lightning Zaps intégrés'] },
{ aspect: 'Modération', values: ["Administrateurs d'instance", 'Client-side, choix des relays'] }
];
const simplexPoints: { icon: IconName; title: string; description: string }[] = [
{
icon: 'user-x',
title: "Pas d'identifiants",
description:
"Aucun numéro de téléphone, email ou nom d'utilisateur. Vos contacts ne se connaissent pas entre eux."
},
{
icon: 'lock',
title: 'Chiffrement E2E',
description:
'Chiffrement de bout en bout par défaut. Même les serveurs SimpleX ne peuvent pas voir vos messages.'
},
{
icon: 'globe',
title: 'Décentralisé',
description:
'Utilisez les serveurs publics ou hébergez les vôtres. Les utilisateurs peuvent utiliser différents serveurs.'
},
{
icon: 'github',
title: 'Open source',
description:
'Code open source audité. Client en Haskell, applications iOS, Android et desktop.'
}
];
</script>
<svelte:head>
<title>Nostr — Le Grand Atlas du Fédiverse</title>
<meta
name="description"
content="Nostr, protocole ouvert et résistant à la censure : clés cryptographiques, relays, 18 clients à découvrir et comparaison avec ActivityPub."
/>
</svelte:head>
<div class="page" style:--page-accent="var(--accent-3)">
<!-- Hero -->
<section class="hero" aria-labelledby="nostr-title">
<div class="center stack hero-inner">
<p class="badge">
<Icon name="zap" size={16} />
<span>Écosystème voisin</span>
</p>
<h1 id="nostr-title">
Nostr :
<span class="gradient-text">Notes and Other Stuff<br />Transmitted by Relays</span>
</h1>
<p class="lede">
Un protocole ouvert, simple et résistant à la censure pour créer des réseaux sociaux
décentralisés. Pas d'instances, pas de serveurs — juste des <strong>clés</strong> et des
<strong>relays</strong>.
</p>
<div class="cluster cta">
<a class="btn btn-primary" href="https://nostr.com/" target="_blank" rel="noopener noreferrer">
<Icon name="external" size={16} />
Découvrir Nostr
<span class="visually-hidden">(nouvel onglet)</span>
</a>
<a
class="btn btn-ghost"
href="https://github.com/nostr-protocol/nips"
target="_blank"
rel="noopener noreferrer"
>
<Icon name="github" size={16} />
NIPs (specs)
<span class="visually-hidden">(nouvel onglet)</span>
</a>
</div>
</div>
</section>
<!-- Comment ça marche -->
<section class="section section-alt" aria-labelledby="how-title">
<div class="center stack">
<h2 id="how-title" class="section-title">Comment ça <span class="accent-text">marche</span> ?</h2>
<StepsGrid items={steps} />
</div>
</section>
<!-- Pourquoi Nostr -->
<section class="section" aria-labelledby="why-title">
<div class="center stack">
<h2 id="why-title" class="section-title">Pourquoi <span class="accent-text">Nostr</span> ?</h2>
<FeatureGrid items={features} min="19rem" />
</div>
</section>
<!-- Clients Nostr -->
<section class="section section-alt" aria-labelledby="clients-title">
<div class="center stack">
<h2 id="clients-title" class="section-title">
Les clients <span class="accent-text">Nostr</span>
</h2>
<p class="section-lede">
Écosystème riche de clients web, mobiles et desktop. Choisissez celui qui correspond à vos
besoins.
</p>
<div class="clients-cq">
<ul class="grid clients-grid" role="list" style:--grid-min="19rem">
{#each clients as sw (sw.id)}
{@const open = openId === sw.id}
<li class="client-card" class:open>
<h3 class="client-heading">
<button
type="button"
aria-expanded={open}
aria-controls="client-details-{sw.id}"
id="client-btn-{sw.id}"
onclick={() => toggle(sw.id)}
>
<span class="client-initial" aria-hidden="true">{sw.name.charAt(0)}</span>
<span class="client-names">
<span class="client-name">{sw.name}</span>
<span class="client-lang">{sw.language}</span>
</span>
<span class="client-chevron" class:open>
<Icon name="chevron" size={18} />
</span>
</button>
</h3>
<p class="client-desc">{sw.description}</p>
<p class="client-etat"><span class="etat-badge">{sw.etat}</span></p>
{#if open}
<div
class="client-details"
id="client-details-{sw.id}"
role="region"
aria-labelledby="client-btn-{sw.id}"
>
<dl class="client-facts">
<div>
<dt>Licence</dt>
<dd>{sw.licence}</dd>
</div>
<div>
<dt>Créé en</dt>
<dd>{sw.createdYear ?? '—'}</dd>
</div>
</dl>
<div class="cluster">
<a
class="btn btn-primary btn-small"
href={sw.site}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="external" size={14} />
{t('common.officialSite')}
<span class="visually-hidden">(nouvel onglet)</span>
</a>
<a
class="btn btn-ghost btn-small"
href={sw.github}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="github" size={14} />
{t('common.sourceCode')}
<span class="visually-hidden">(nouvel onglet)</span>
</a>
</div>
</div>
{/if}
</li>
{/each}
</ul>
</div>
</div>
</section>
<!-- Tableau comparatif -->
<section class="section" aria-labelledby="compare-title">
<div class="center stack" style:--center-max="60rem">
<h2 id="compare-title" class="section-title">
ActivityPub <span class="vs">vs</span> <span class="accent-text">Nostr</span>
</h2>
<CompareTable
caption="Deux approches différentes de la décentralisation sociale. L'une n'exclut pas l'autre — des ponts sont en construction."
columns={['ActivityPub (Fédiverse)', 'Nostr']}
rows={comparison}
highlight={1}
ariaLabel="Tableau comparatif ActivityPub et Nostr faire défiler horizontalement si nécessaire"
/>
</div>
</section>
<!-- Encart SimpleX Chat -->
<section class="section section-deep" aria-labelledby="simplex-title">
<div class="center" style:--center-max="60rem">
<article class="simplex-panel stack">
<header class="simplex-head">
<span class="simplex-icon"><Icon name="shield" size={24} /></span>
<div>
<p class="kicker">Alternative / Complément</p>
<h2 id="simplex-title">SimpleX Chat</h2>
</div>
</header>
<p>
<strong>SimpleX Chat</strong> est une messagerie décentralisée de nouvelle génération qui prend
une approche radicalement différente :
<strong>pas d'identifiants utilisateur du tout</strong>. Contrairement au Fédiverse (où vous
avez @utilisateur@instance) ou à Nostr (où vous avez une clé publique), SimpleX n'a aucun
identifiant global. Chaque contact est un lien unique, chaque appareil est indépendant.
</p>
<ul class="grid simplex-points" role="list" style:--grid-min="16rem">
{#each simplexPoints as point (point.title)}
<li class="simplex-point">
<h3>
<Icon name={point.icon} size={16} />
{point.title}
</h3>
<p>{point.description}</p>
</li>
{/each}
</ul>
<div class="cluster">
<a
class="btn btn-simplex"
href="https://simplex.chat/"
target="_blank"
rel="noopener noreferrer"
>
<Icon name="external" size={16} />
Site officiel
<span class="visually-hidden">(nouvel onglet)</span>
</a>
<a
class="btn btn-ghost"
href="https://github.com/simplex-chat/simplex-chat"
target="_blank"
rel="noopener noreferrer"
>
<Icon name="github" size={16} />
Code source
<span class="visually-hidden">(nouvel onglet)</span>
</a>
<p class="simplex-badges">
<span class="mono">AGPL-3.0</span>
<span aria-hidden="true">·</span>
<span class="mono">Haskell</span>
</p>
</div>
</article>
</div>
</section>
</div>
<style>
.page {
--page-accent: var(--accent-3);
--page-accent-text: var(--accent-3-text);
--page-accent-strong: var(--accent-3-strong);
background: var(--surface);
}
/* ---------- Hero ---------- */
.hero {
padding-block-start: calc(var(--header-h) + var(--space-6));
padding-block-end: var(--space-6);
}
.hero-inner {
align-items: center;
text-align: center;
--stack-gap: var(--space-4);
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-pill);
border: 1px solid color-mix(in oklch, var(--page-accent) 40%, transparent);
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
color: var(--page-accent-text);
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
h1 {
max-inline-size: 22ch;
}
.gradient-text {
background: linear-gradient(135deg, var(--accent-3-text), var(--accent-2-text));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.lede {
color: var(--ink-soft);
font-size: var(--text-2);
max-inline-size: 58ch;
}
.lede strong {
color: var(--ink);
}
.cta {
justify-content: center;
--cluster-gap: var(--space-3);
}
/* ---------- Sections ---------- */
.section {
padding-block: var(--space-6);
}
.section-alt {
background: var(--ocean);
}
.section-deep {
background: var(--abyss);
}
.section > .center {
--stack-gap: var(--space-5);
}
.section-title {
text-align: center;
}
.accent-text {
color: var(--page-accent-text);
}
.vs {
color: var(--muted);
font-weight: 400;
}
.section-lede {
text-align: center;
color: var(--ink-soft);
margin-inline: auto;
}
/* ---------- Boutons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-block-size: var(--tap-target);
padding-inline: var(--space-4);
border-radius: var(--radius-pill);
font-weight: 600;
font-size: var(--text-small);
text-decoration: none;
transition: transform var(--duration-1) var(--ease-out);
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background: var(--page-accent-strong);
color: var(--on-accent);
}
.btn-ghost {
border: 1px solid var(--glass-border);
background: var(--glass);
color: var(--ink);
}
.btn-small {
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
}
/* ---------- Accordéon clients ---------- */
.clients-cq {
container-type: inline-size;
}
.clients-grid {
--grid-min: 100%;
}
@container (min-width: 40rem) {
.clients-grid {
--grid-min: 19rem;
}
}
.client-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
transition: border-color var(--duration-1) var(--ease-out);
}
.client-card.open {
border-color: color-mix(in oklch, var(--page-accent) 50%, transparent);
}
.client-heading {
font-size: var(--text-1);
}
.client-heading button {
display: flex;
align-items: center;
gap: var(--space-3);
inline-size: 100%;
min-block-size: var(--tap-target);
padding: var(--space-1);
border-radius: var(--radius-1);
color: var(--ink);
text-align: start;
}
.client-initial {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.5rem;
block-size: 2.5rem;
flex-shrink: 0;
border-radius: var(--radius-1);
background: color-mix(in oklch, var(--page-accent) 16%, transparent);
color: var(--page-accent-text);
font-weight: 700;
}
.client-names {
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.client-name {
font-weight: 600;
}
.client-heading button:hover .client-name,
.client-card.open .client-name {
color: var(--page-accent-text);
}
.client-lang {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--muted);
}
.client-chevron {
margin-inline-start: auto;
color: var(--muted);
transition: transform var(--duration-2) var(--ease-out);
}
.client-chevron.open {
transform: rotate(180deg);
}
.client-desc {
font-size: var(--text-small);
color: var(--ink-soft);
}
.client-etat {
margin-block-start: auto;
}
.etat-badge {
display: inline-block;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
font-size: 0.75rem;
font-weight: 600;
color: var(--ink-soft);
}
.client-details {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-block-start: var(--space-3);
border-block-start: 1px solid var(--border);
}
.client-facts {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-family: var(--font-mono);
font-size: 0.75rem;
}
.client-facts > div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.client-facts dt {
color: var(--muted);
}
.client-facts dd {
color: var(--ink);
}
/* ---------- Encart SimpleX ---------- */
.simplex-panel {
--stack-gap: var(--space-4);
padding: var(--space-5);
background: var(--glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-3);
}
.simplex-head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.simplex-icon {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 3rem;
block-size: 3rem;
border-radius: var(--radius-2);
color: var(--accent-4-text);
background: color-mix(in oklch, var(--accent-4) 14%, transparent);
}
.kicker {
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.simplex-panel p {
color: var(--ink-soft);
}
.simplex-panel p strong {
color: var(--ink);
}
.simplex-point {
padding: var(--space-3);
border-radius: var(--radius-2);
border: 1px solid color-mix(in oklch, var(--accent-4) 18%, transparent);
background: color-mix(in oklch, var(--accent-4) 8%, transparent);
}
.simplex-point h3 {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-1);
color: var(--accent-4-text);
margin-block-end: var(--space-1);
}
.simplex-point p {
font-size: var(--text-small);
}
.btn-simplex {
background: var(--accent-4-strong);
color: var(--on-accent);
}
.simplex-badges {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.75rem;
color: var(--muted);
}
</style>
@@ -0,0 +1,497 @@
<script lang="ts">
import Icon from '$lib/components/pages/Icon.svelte';
import FeatureGrid from '$lib/components/pages/FeatureGrid.svelte';
import type { FeatureItem } from '$lib/components/pages/FeatureGrid.svelte';
import StepsGrid from '$lib/components/pages/StepsGrid.svelte';
import type { StepItem } from '$lib/components/pages/StepsGrid.svelte';
import CompareTable from '$lib/components/pages/CompareTable.svelte';
import type { CompareRow } from '$lib/components/pages/CompareTable.svelte';
import type { IconName } from '$lib/components/pages/Icon.svelte';
const pageStats = [
{ value: '1M+', label: 'Téléchargements' },
{ value: '100+', label: 'Pays' },
{ value: 'AGPL-3.0', label: 'Licence' },
{ value: '100%', label: 'Open source' }
];
const steps: StepItem[] = [
{
step: '01',
icon: 'key',
title: "L'application crée vos clés",
description:
'Votre appareil génère des clés cryptographiques. Aucune information personnelle requise.'
},
{
step: '02',
icon: 'users',
title: 'Connectez via des liens',
description:
"Chaque contact utilise un lien d'invitation unique et jetable. Partagez par QR code, URL ou message."
},
{
step: '03',
icon: 'message',
title: 'Communiquez en privé',
description:
'Messages, groupes et appels chiffrés de bout en bout. Vos métadonnées restent privées.'
}
];
const features: FeatureItem[] = [
{
icon: 'user-x',
title: 'Zéro identifiant',
description:
"Aucun numéro de téléphone, email, nom d'utilisateur ou mot de passe. Vos contacts ne se connaissent pas entre eux."
},
{
icon: 'eye-off',
title: 'Métadonnées minimisées',
description:
'SimpleX ne collecte pas vos métadonnées. Pas de serveur central qui connaît votre réseau social.'
},
{
icon: 'lock',
title: 'Chiffrement E2E de nouvelle génération',
description:
'Double Ratchet Algorithm (comme Signal) + chiffrement post-quantique. Même les serveurs ne peuvent pas lire vos messages.'
},
{
icon: 'server-off',
title: 'Sans serveur de métadonnées',
description:
"Contrairement à Signal ou WhatsApp, il n'y a pas de serveur central qui connaît qui parle avec qui."
},
{
icon: 'fingerprint',
title: 'Connexions anonymes',
description:
"Chaque contact utilise un lien d'invitation unique jetable. Votre identité n'est jamais révélée au serveur."
},
{
icon: 'globe',
title: 'Réseau décentralisé',
description:
'Utilisez les serveurs publics gratuits ou hébergez le vôtre. Aucun point unique de défaillance.'
},
{
icon: 'message',
title: 'Groupes de grande taille',
description:
'Groupes chiffrés de plusieurs centaines de membres avec administration décentralisée.'
},
{
icon: 'phone',
title: 'Appels vocaux et vidéo',
description:
'Appels chiffrés de bout en bout directement entre appareils, sans passer par un serveur.'
}
];
const comparison: CompareRow[] = [
{
aspect: 'Identifiants',
values: ['Aucun', 'Numéro de téléphone', 'Numéro de téléphone', "Nom d'utilisateur"]
},
{
aspect: 'Chiffrement',
values: ['Double Ratchet + PQ', 'Double Ratchet', 'Signal Protocol', 'Olm/Megolm']
},
{
aspect: 'Métadonnées',
values: ['Minimisées', 'Collectées (contacts)', 'Collectées (Meta)', 'Sur le serveur']
},
{
aspect: 'Serveur',
values: ['Sans métadonnées', 'Central (Signal LLC)', 'Central (Meta)', 'Instance choisie']
},
{
aspect: 'Code source',
values: ['AGPL-3.0 (100%)', 'GPL-3.0 (client)', 'Fermé', 'Apache-2.0']
},
{
aspect: 'Fédération',
values: ['Non (intentionnel)', 'Non', 'Non', 'Oui (fédéré)']
}
];
const useCases: { icon: IconName; title: string; description: string }[] = [
{
icon: 'eye-off',
title: 'Journalisme',
description: 'Protection des sources avec des connexions anonymes et sans métadonnées.'
},
{
icon: 'shield',
title: 'Activisme',
description: 'Communication résistante à la surveillance étatique ou corporative.'
},
{
icon: 'users',
title: 'Communautés',
description: 'Groupes privés avec administration décentralisée et grande capacité.'
},
{
icon: 'lock',
title: 'Entreprise',
description: 'Messagerie interne auto-hébergée avec confidentialité garantie.'
}
];
const chips = ['Messagerie privée', 'Sans métadonnées', 'Complément Fediverse', 'Chiffrement PQ'];
</script>
<svelte:head>
<title>SimpleX Chat — Le Grand Atlas du Fédiverse</title>
<meta
name="description"
content="SimpleX Chat, la première messagerie sans identifiants d'utilisateur : chiffrement de bout en bout post-quantique, métadonnées minimisées, comparaison avec Signal, WhatsApp et Matrix."
/>
</svelte:head>
<div class="page">
<!-- Hero -->
<section class="hero" aria-labelledby="simplex-title">
<div class="center stack hero-inner">
<p class="badge">
<Icon name="shield" size={16} />
<span>Messagerie de nouvelle génération</span>
</p>
<h1 id="simplex-title">La messagerie <span class="accent-text">sans identifiants</span></h1>
<p class="lede">
SimpleX Chat est la première messagerie au monde
<strong>sans identifiants d'utilisateur</strong>. Pas de numéro de téléphone, pas d'email,
pas de nom d'utilisateur. Uniquement des connexions anonymes avec chiffrement de bout en
bout post-quantique.
</p>
<div class="cluster cta">
<a class="btn btn-primary" href="https://simplex.chat/" target="_blank" rel="noopener noreferrer">
<Icon name="external" size={16} />
Site officiel
<span class="visually-hidden">(nouvel onglet)</span>
</a>
<a
class="btn btn-ghost"
href="https://github.com/simplex-chat/simplex-chat"
target="_blank"
rel="noopener noreferrer"
>
<Icon name="github" size={16} />
Code source
<span class="visually-hidden">(nouvel onglet)</span>
</a>
</div>
<ul class="grid hero-stats" role="list" style:--grid-min="9rem">
{#each pageStats as stat (stat.label)}
<li class="stat-card">
<span class="stat-value">{stat.value}</span>
<span class="stat-label">{stat.label}</span>
</li>
{/each}
</ul>
</div>
</section>
<!-- Comment ça marche -->
<section class="section section-alt" aria-labelledby="how-title">
<div class="center stack">
<h2 id="how-title" class="section-title">Comment ça <span class="accent-text">marche</span> ?</h2>
<StepsGrid items={steps} />
</div>
</section>
<!-- Pourquoi SimpleX -->
<section class="section" aria-labelledby="why-title">
<div class="center stack">
<h2 id="why-title" class="section-title">Pourquoi <span class="accent-text">SimpleX</span> ?</h2>
<FeatureGrid items={features} min="15rem" />
</div>
</section>
<!-- Tableau comparatif -->
<section class="section section-alt" aria-labelledby="compare-title">
<div class="center stack" style:--center-max="62rem">
<h2 id="compare-title" class="section-title">
SimpleX <span class="vs">vs</span> les autres
</h2>
<CompareTable
caption="SimpleX se distingue des messageries centralisées comme des outils fédérés : aucun identifiant, métadonnées minimisées."
columns={['SimpleX', 'Signal', 'WhatsApp', 'Matrix']}
rows={comparison}
highlight={0}
ariaLabel="Tableau comparatif SimpleX, Signal, WhatsApp et Matrix faire défiler horizontalement si nécessaire"
/>
</div>
</section>
<!-- Cas d'usage -->
<section class="section" aria-labelledby="usecases-title">
<div class="center stack">
<h2 id="usecases-title" class="section-title">Cas d'<span class="accent-text">usage</span></h2>
<div class="usecases-cq">
<ul class="grid" role="list" style:--grid-min="15rem">
{#each useCases as useCase (useCase.title)}
<li class="usecase-card">
<span class="usecase-icon"><Icon name={useCase.icon} size={24} /></span>
<h3>{useCase.title}</h3>
<p>{useCase.description}</p>
</li>
{/each}
</ul>
</div>
</div>
</section>
<!-- SimpleX et le Fédiverse -->
<section class="section section-deep" aria-labelledby="fediverse-title">
<div class="center" style:--center-max="60rem">
<article class="fediverse-panel stack">
<h2 id="fediverse-title">SimpleX et le <span class="accent-text">Fédiverse</span></h2>
<p>
SimpleX Chat n'est pas fédéré au sens ActivityPub — et c'est
<strong>intentionnel</strong>. L'absence de fédération est un choix de design pour
préserver la confidentialité.
</p>
<p>
Cependant, SimpleX complète parfaitement le Fédiverse : utilisez Mastodon/Pleroma pour
votre présence sociale publique, et SimpleX pour vos communications privées sensibles.
</p>
<ul class="cluster chips" role="list">
{#each chips as chip (chip)}
<li class="chip mono">{chip}</li>
{/each}
</ul>
</article>
</div>
</section>
</div>
<style>
.page {
--page-accent: var(--accent-4);
--page-accent-text: var(--accent-4-text);
--page-accent-strong: var(--accent-4-strong);
background: var(--surface);
}
/* ---------- Hero ---------- */
.hero {
padding-block-start: calc(var(--header-h) + var(--space-6));
padding-block-end: var(--space-6);
}
.hero-inner {
align-items: center;
text-align: center;
--stack-gap: var(--space-4);
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-pill);
border: 1px solid color-mix(in oklch, var(--page-accent) 40%, transparent);
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
color: var(--page-accent-text);
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
h1 {
max-inline-size: 22ch;
}
.accent-text {
color: var(--page-accent-text);
}
.lede {
color: var(--ink-soft);
font-size: var(--text-2);
max-inline-size: 58ch;
}
.lede strong {
color: var(--ink);
}
.cta {
justify-content: center;
--cluster-gap: var(--space-3);
}
.hero-stats {
inline-size: min(100%, 34rem);
margin-block-start: var(--space-3);
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-3);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
}
.stat-value {
font-family: var(--font-display);
font-size: var(--text-2);
font-weight: 700;
color: var(--page-accent-text);
}
.stat-label {
font-size: 0.75rem;
color: var(--ink-soft);
}
/* ---------- Sections ---------- */
.section {
padding-block: var(--space-6);
}
.section-alt {
background: var(--ocean);
}
.section-deep {
background: var(--abyss);
}
.section > .center {
--stack-gap: var(--space-5);
}
.section-title {
text-align: center;
}
.vs {
color: var(--muted);
font-weight: 400;
}
/* ---------- Boutons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-block-size: var(--tap-target);
padding-inline: var(--space-4);
border-radius: var(--radius-pill);
font-weight: 600;
font-size: var(--text-small);
text-decoration: none;
transition: transform var(--duration-1) var(--ease-out);
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background: var(--page-accent-strong);
color: var(--on-accent);
}
.btn-ghost {
border: 1px solid var(--glass-border);
background: var(--glass);
color: var(--ink);
}
/* ---------- Cas d'usage ---------- */
.usecases-cq {
container-type: inline-size;
}
.usecase-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-2);
padding: var(--space-4);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
}
.usecase-icon {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 3rem;
block-size: 3rem;
border-radius: var(--radius-2);
color: var(--page-accent-text);
background: color-mix(in oklch, var(--page-accent) 14%, transparent);
}
.usecase-card h3 {
font-size: var(--text-1);
}
.usecase-card p {
font-size: var(--text-small);
color: var(--ink-soft);
}
@container (min-width: 40rem) {
.usecase-card {
align-items: flex-start;
text-align: start;
}
}
/* ---------- Panel Fédiverse ---------- */
.fediverse-panel {
--stack-gap: var(--space-4);
padding: var(--space-5);
background: var(--glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-3);
}
.fediverse-panel p {
color: var(--ink-soft);
}
.fediverse-panel p strong {
color: var(--ink);
}
.chips {
--cluster-gap: var(--space-2);
}
.chip {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-pill);
font-size: 0.75rem;
background: color-mix(in oklch, var(--page-accent) 12%, transparent);
color: var(--page-accent-text);
}
</style>
+122
View File
@@ -0,0 +1,122 @@
/* Service Worker — PWA offline-first.
Stratégies :
- Cache-First pour les assets statiques pré-cachés (build + fichiers static)
- Network-First pour les données JSON (/data/*) et les navigations, repli cache
- Stale-While-Revalidate pour le reste same-origin
Note doctrine : l'Atlas est un site de consultation sans actions mutatives
(aucun POST/formulaire serveur) ; il n'y a donc pas de file d'attente
d'actions hors-ligne à implémenter — la structure est prête si besoin. */
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, prerendered, version } from '$service-worker';
const worker = self as unknown as ServiceWorkerGlobalScope;
const PRECACHE = `atlas-precache-${version}`;
const RUNTIME = `atlas-runtime-${version}`;
// Les listes $service-worker incluent le base path (/fediverse) :
// build = chunks Vite, files = static/, prerendered = pages HTML prérendues.
const PRECACHE_URLS = [...build, ...files, ...prerendered];
worker.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(PRECACHE)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => worker.skipWaiting())
);
});
worker.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((key) => key !== PRECACHE && key !== RUNTIME).map((key) => caches.delete(key))
)
)
.then(() => worker.clients.claim())
);
});
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone());
}
return response;
}
async function networkFirst(request: Request): Promise<Response> {
const cache = await caches.open(RUNTIME);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch {
const url = new URL(request.url);
// Candidates : copie de la visite, version canonique avec slash final
// (trailingSlash: 'always' → pages en dossiers), puis la racine du scope
// (registration.scope inclut le base path, ex. https://o-k-i.net/fediverse/).
const candidates = [
request.url,
`${url.origin}${url.pathname.replace(/\/?$/, '/')}`,
worker.registration.scope
];
for (const candidate of candidates) {
const hit = await caches.match(candidate);
if (hit) return hit;
}
throw new Error('Hors connexion et rien en cache');
}
}
async function staleWhileRevalidate(request: Request): Promise<Response> {
const cache = await caches.open(RUNTIME);
const cached = await cache.match(request);
const fetched = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => cached as Promise<Response>);
return cached ?? fetched;
}
worker.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== location.origin) return;
// Données JSON téléchargeables : fraîcheur d'abord, repli cache.
if (url.pathname.startsWith('/data/')) {
event.respondWith(networkFirst(request));
return;
}
// Navigations : réseau d'abord (contenu frais), cache hors-ligne.
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
// Assets immuables pré-cachés (JS/CSS/fonts/icônes versionnés).
if (PRECACHE_URLS.includes(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
event.respondWith(staleWhileRevalidate(request));
});
+116
View File
@@ -0,0 +1,116 @@
/* base.css — HTML sémantique, focus visible.
Les @font-face sont dans app.html (chemins %sveltekit.assets% = base /fediverse). */
@layer base {
body {
font-family: var(--font-body);
font-size: var(--text-1);
color: var(--ink);
background: var(--surface);
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 700;
line-height: 1.15;
text-wrap: balance;
}
h1 {
font-size: var(--text-4);
}
h2 {
font-size: var(--text-3);
}
h3 {
font-size: var(--text-2);
}
p {
max-width: var(--measure);
line-height: 1.6;
}
a {
color: var(--accent-2-text);
text-underline-offset: 0.2em;
}
code,
kbd,
pre,
.mono {
font-family: var(--font-mono);
font-size: 0.9em;
}
small,
.small {
font-size: var(--text-small);
}
/* Focus visible sur tous les éléments interactifs */
:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
border-radius: var(--radius-1);
}
::selection {
background: var(--accent);
color: var(--on-accent);
}
/* Classe utilitaire lecteur d'écran */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: fixed;
inset-block-start: var(--space-2);
inset-inline-start: var(--space-2);
z-index: 100;
padding: var(--space-2) var(--space-3);
background: var(--accent);
color: var(--on-accent);
border-radius: var(--radius-1);
transform: translateY(-200%);
transition: transform var(--duration-1) var(--ease-out);
}
.skip-link:focus-visible {
transform: translateY(0);
}
/* Éthique du mouvement : kill-switch global */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Contraste renforcé si demandé */
@media (prefers-contrast: more) {
:root {
--border: var(--ink);
--muted: var(--ink);
}
}
}
+55
View File
@@ -0,0 +1,55 @@
/* layouts.css — primitives Every Layout : stack, cluster, sidebar, center, grid */
@layer layouts {
/* Stack : flux vertical à rythme régulier */
.stack {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: var(--stack-gap, var(--space-3));
}
/* Cluster : groupe horizontal qui s'enroule (pills, boutons, nav) */
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--cluster-gap, var(--space-2));
align-items: center;
}
/* Sidebar : panneau latéral + contenu fluide, replie en colonne */
.sidebar {
display: flex;
flex-wrap: wrap;
gap: var(--sidebar-gap, var(--space-4));
}
.sidebar > :first-child {
flex-basis: var(--sidebar-width, 20rem);
flex-grow: 1;
}
.sidebar > :last-child {
flex-basis: 0;
flex-grow: 999;
min-inline-size: var(--sidebar-content-min, 55%);
}
/* Center : centrage horizontal à mesure maximale */
.center {
box-sizing: content-box;
margin-inline: auto;
max-inline-size: var(--center-max, var(--content-max));
padding-inline: var(--center-padding, var(--space-3));
}
/* Grid : répartition auto-adaptative sans media query */
.grid {
display: grid;
gap: var(--grid-gap, var(--space-3));
grid-template-columns: repeat(
auto-fill,
minmax(min(100%, var(--grid-min, 18rem)), 1fr)
);
}
}
+73
View File
@@ -0,0 +1,73 @@
/* reset.css — reset moderne minimal */
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
/* jamais de font-size en px : la taille utilisateur est respectée */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
min-height: 100dvh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
background: none;
border: none;
padding: 0;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
ul[role='list'],
ol[role='list'] {
list-style: none;
padding: 0;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
}
@@ -0,0 +1,74 @@
/* themes/default.css — tokens sémantiques du projet (couleurs de marque, surfaces, états).
Toutes les valeurs dérivent des tokens primitifs ou de la palette oklch du projet. */
@layer tokens {
:root {
/* Surfaces profondes (identité « grand large ») */
--surface-3: light-dark(oklch(92% 0.015 90), oklch(24% 0.03 250));
--abyss: light-dark(oklch(96% 0.01 90), oklch(12% 0.025 255)); /* fond le plus profond #001219 */
--ocean: light-dark(oklch(93% 0.02 90), oklch(19% 0.045 250)); /* fond intermédiaire #012A4A */
/* Texte */
--ink-soft: light-dark(oklch(35% 0.03 260), oklch(82% 0.04 235)); /* #A9D6E5 en sombre, AA en clair */
--on-accent: oklch(99% 0.01 90);
/* Bordures & verre */
--border: light-dark(oklch(86% 0.015 260), oklch(34% 0.03 250));
--border-strong: light-dark(oklch(75% 0.02 260), oklch(45% 0.04 250));
--glass: light-dark(oklch(99% 0.005 90 / 0.75), oklch(17% 0.03 250 / 0.72));
--glass-border: light-dark(oklch(80% 0.02 260 / 0.6), oklch(50% 0.05 250 / 0.4));
/* États d'activité (doublés par du texte, jamais couleur seule) */
--status-active: light-dark(oklch(55% 0.14 165), oklch(75% 0.16 165));
--status-maintenance: light-dark(oklch(52% 0.12 90), oklch(80% 0.15 90));
--status-experimental: var(--accent);
--status-historical: var(--muted);
/* Compatibilité ActivityPub (variantes « texte sur fond teinté », AA vérifié) */
--compat-full: light-dark(oklch(50% 0.13 165), oklch(75% 0.16 165));
--compat-partial: light-dark(oklch(52% 0.12 90), oklch(80% 0.15 90));
--compat-extension: light-dark(oklch(45% 0.12 250), oklch(75% 0.13 240));
--compat-none: light-dark(oklch(50% 0.19 350), oklch(72% 0.18 350));
/* Variantes « texte » des accents — AA (≥ 4.5:1) sur surfaces claires et sombres.
Les tokens --accent* d'origine restent dédiés aux fonds/bordures décoratives. */
--accent-text: light-dark(oklch(50% 0.19 350), oklch(72% 0.18 350));
--accent-2-text: light-dark(oklch(45% 0.12 250), oklch(75% 0.13 240));
--accent-3-text: light-dark(oklch(48% 0.15 70), oklch(75% 0.16 75));
--accent-4-text: light-dark(oklch(45% 0.13 165), oklch(75% 0.16 165));
/* Variantes « fond » des accents pour boutons avec texte --on-accent (AA ≥ 4.5:1) */
--accent-strong: oklch(48% 0.18 350);
--accent-3-strong: oklch(46% 0.14 70);
--accent-4-strong: oklch(43% 0.12 165);
/* Familles de polices (2 fichiers WOFF2 auto-hébergés, piles système en repli) */
--font-display: 'Space Grotesk', 'Inter', system-ui, sans-serif;
--font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
/* Rayons, ombres, halos */
--radius-1: 0.5rem;
--radius-2: 1rem;
--radius-3: 1.5rem;
--radius-pill: 999px;
--shadow-1: 0 0.125rem 0.5rem oklch(10% 0.02 260 / 0.15);
--shadow-2: 0 0.5rem 2rem oklch(10% 0.02 260 / 0.25);
--glow-accent: 0 0 1.5rem oklch(55% 0.2 350 / 0.45);
--glow-accent-2: 0 0 1.5rem oklch(70% 0.15 250 / 0.35);
/* Dégradés de marque (les usages sont tous du texte en background-clip :
construit sur les variantes « texte » pour rester AA dans les deux modes) */
--gradient-brand: linear-gradient(135deg, var(--accent-text), var(--accent-2-text));
--gradient-abyss: linear-gradient(180deg, var(--ocean), var(--abyss));
/* Structure */
--header-h: 4.5rem;
--content-max: 75rem;
/* Transitions (désactivées via prefers-reduced-motion dans base.css) */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--duration-1: 150ms;
--duration-2: 300ms;
}
}
+37
View File
@@ -0,0 +1,37 @@
/* tokens.css — Design tokens (oklch, typo fluide, espacements)
Méthodologie modus-vivendi : color-scheme light/dark via light-dark(). */
@layer tokens {
:root {
color-scheme: light dark;
/* Couleurs de base */
--ink: light-dark(oklch(22% 0.02 260), oklch(92% 0.01 260));
--surface: light-dark(oklch(98% 0.005 90), oklch(15% 0.02 260));
--surface-2: light-dark(oklch(95% 0.01 90), oklch(20% 0.02 260));
--accent: oklch(55% 0.2 350); /* rose coral #F72585 */
--accent-2: oklch(70% 0.15 250); /* bleu #4CC9F0 */
--accent-3: oklch(65% 0.18 80); /* orange Nostr #F7931A */
--accent-4: oklch(70% 0.15 160); /* vert SimpleX #06D6A0 */
--muted: light-dark(oklch(45% 0.02 260), oklch(70% 0.02 260));
/* Typographie fluide */
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
--text-2: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem);
--text-3: clamp(1.5rem, 1.3rem + 1vw, 2rem);
--text-4: clamp(2rem, 1.8rem + 1.2vw, 3rem);
--text-small: clamp(0.875rem, 0.85rem + 0.1vw, 0.9375rem);
/* Espacements */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
/* Divers */
--tap-target: 48px;
--measure: 65ch;
}
}