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
@@ -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>