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:
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { getLocale, LOCALES, setLocale, t } from '$lib/i18n/i18n.svelte';
|
||||
import type { Locale } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
const projectLinks = [
|
||||
{ href: `${base}/#map`, labelKey: 'footer.about' },
|
||||
{ href: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', labelKey: 'footer.license' },
|
||||
{ href: 'https://github.com/', labelKey: 'footer.source' },
|
||||
{ href: `${base}/data/fediverse.json`, labelKey: 'footer.api' }
|
||||
] as const;
|
||||
|
||||
const resourceLinks = [
|
||||
{ href: 'https://activitypub.rocks/', label: 'ActivityPub.rocks' },
|
||||
{ href: 'https://fedidb.org/', label: 'FediDB' },
|
||||
{ href: 'https://jointhefediverse.net/', label: 'Join the Fediverse' },
|
||||
{ href: 'https://fediverse.info/', label: 'Fediverse.info' }
|
||||
];
|
||||
|
||||
const communityLinks = [
|
||||
{ href: 'https://joinmastodon.org/fr', label: 'Mastodon' },
|
||||
{ href: 'https://join-lemmy.org/', label: 'Lemmy' },
|
||||
{ href: 'https://joinpeertube.org/', label: 'PeerTube' },
|
||||
{ href: 'https://pixelfed.org/', label: 'PixelFed' }
|
||||
];
|
||||
|
||||
const downloads = [
|
||||
{ href: `${base}/data/fediverse.json`, label: 'JSON' },
|
||||
{ href: `${base}/data/fediverse.yaml`, label: 'YAML' },
|
||||
{ href: `${base}/infographie-fediverse-2026.svg`, label: 'Infographie SVG' },
|
||||
{ href: `${base}/infographie-fediverse-2026.pdf`, label: 'Infographie PDF' }
|
||||
];
|
||||
|
||||
const currentLocale = $derived(getLocale());
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<div class="center stack footer-stack">
|
||||
<div class="footer-grid">
|
||||
<div class="stack brand-col">
|
||||
<a href="{base}/" class="brand" aria-label={t('header.home')}>
|
||||
<img src="{base}/icons/logo.svg" alt="" width="40" height="40" />
|
||||
<span class="brand-name">Le Grand Atlas du Fédiverse</span>
|
||||
</a>
|
||||
<p class="muted">{t('footer.baseline')}</p>
|
||||
<p class="muted small">Version 4.0 · 2026 · {t('footer.madeIn')}</p>
|
||||
|
||||
<div class="locale-switcher" role="group" aria-label={t('footer.localeLabel')}>
|
||||
{#each LOCALES as locale (locale.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="locale-btn"
|
||||
aria-pressed={currentLocale === locale.id}
|
||||
onclick={() => setLocale(locale.id as Locale)}
|
||||
>
|
||||
{locale.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.project')}>
|
||||
<h3>{t('footer.project')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each projectLinks as link (link.href)}
|
||||
<li>
|
||||
<a href={link.href} {...link.href.startsWith('http') ? { rel: 'external' } : {}}>
|
||||
{t(link.labelKey)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.resources')}>
|
||||
<h3>{t('footer.resources')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each resourceLinks as link (link.href)}
|
||||
<li><a href={link.href} rel="external">{link.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="stack link-col" aria-label={t('footer.community')}>
|
||||
<h3>{t('footer.community')}</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each communityLinks as link (link.href)}
|
||||
<li><a href={link.href} rel="external">{link.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="downloads cluster" aria-label="Téléchargements">
|
||||
{#each downloads as dl (dl.href)}
|
||||
<a href={dl.href} class="download-chip" download>{dl.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bottom-bar stack">
|
||||
<p class="small muted">{t('footer.rights')}</p>
|
||||
<p class="small muted">{t('footer.credits')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
footer {
|
||||
background: var(--abyss);
|
||||
border-block-start: 1px solid var(--border);
|
||||
padding-block: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.footer-stack {
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-2);
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.brand-col {
|
||||
--stack-gap: var(--space-3);
|
||||
max-inline-size: 34rem;
|
||||
}
|
||||
|
||||
.link-col {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.link-col ul {
|
||||
--stack-gap: var(--space-1);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.link-col a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: 2rem;
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-col a:hover {
|
||||
color: var(--accent-2-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.locale-switcher {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
|
||||
.locale-btn {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink-soft);
|
||||
font-weight: 500;
|
||||
transition:
|
||||
color var(--duration-1) var(--ease-out),
|
||||
border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.locale-btn:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.locale-btn[aria-pressed='true'] {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.downloads {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.download-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.download-chip:hover {
|
||||
color: var(--accent-2-text);
|
||||
border-color: var(--accent-2-text);
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
--stack-gap: var(--space-1);
|
||||
border-block-start: 1px solid var(--border);
|
||||
padding-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { base } from '$app/paths';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { canInstall, promptInstall } from '$lib/pwa.svelte';
|
||||
|
||||
let scrolled = $state(false);
|
||||
let hidden = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuButton = $state<HTMLButtonElement | null>(null);
|
||||
let mobileNav = $state<HTMLElement | null>(null);
|
||||
|
||||
const anchors = [
|
||||
{ href: `${base}/#map`, key: 'nav.map' },
|
||||
{ href: `${base}/#protocols`, key: 'nav.protocols' },
|
||||
{ href: `${base}/#timeline`, key: 'nav.timeline' },
|
||||
{ href: `${base}/#catalog`, key: 'nav.catalog' }
|
||||
] as const;
|
||||
|
||||
// Slash final : évite la redirection 301 d'Apache (DirectorySlash)
|
||||
const routes = [
|
||||
{ href: `${base}/nostr/`, key: 'nav.nostr', accent: 'var(--accent-3)' },
|
||||
{ href: `${base}/simplex/`, key: 'nav.simplex', accent: 'var(--accent-4)' }
|
||||
] as const;
|
||||
|
||||
const pathname: string = $derived(page.url.pathname);
|
||||
|
||||
onMount(() => {
|
||||
let lastY = 0;
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY;
|
||||
scrolled = y > 80;
|
||||
// Auto-masquage : disparaît en défilant vers le bas (lecture),
|
||||
// réapparaît dès que l'utilisateur remonte. Jamais masqué en haut
|
||||
// de page ni quand le menu mobile est ouvert.
|
||||
if (y > 160 && y > lastY) {
|
||||
hidden = true;
|
||||
menuOpen = false;
|
||||
} else if (y < lastY || y <= 160) {
|
||||
hidden = false;
|
||||
}
|
||||
lastY = y;
|
||||
};
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
menuOpen = false;
|
||||
menuButton?.focus();
|
||||
}
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
// Ferme le menu mobile à chaque navigation
|
||||
$effect(() => {
|
||||
pathname;
|
||||
menuOpen = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
mobileNav?.querySelector<HTMLElement>('a, button')?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class:scrolled class:hidden>
|
||||
<div class="header-inner center">
|
||||
<a href="{base}/" class="brand" aria-label={t('header.home')}>
|
||||
<img src="{base}/icons/logo.svg" alt="" width="34" height="34" />
|
||||
<span class="wordmark">Atlas</span>
|
||||
</a>
|
||||
|
||||
<nav class="desktop-nav" aria-label={t('nav.main')}>
|
||||
<ul class="cluster" role="list">
|
||||
{#each anchors as link (link.href)}
|
||||
<li><a href={link.href} class="nav-link">{t(link.key)}</a></li>
|
||||
{/each}
|
||||
{#each routes as route (route.href)}
|
||||
<li>
|
||||
<a
|
||||
href={route.href}
|
||||
class="nav-link route-link"
|
||||
style:--link-accent={route.accent}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
>
|
||||
{ t(route.key)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="actions cluster">
|
||||
{#if canInstall()}
|
||||
<button type="button" class="install-btn" onclick={() => promptInstall()}>
|
||||
{t('header.install')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
bind:this={menuButton}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={menuOpen ? t('nav.closeMenu') : t('nav.openMenu')}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{#if menuOpen}
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{:else}
|
||||
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
class="mobile-nav"
|
||||
aria-label={t('nav.main')}
|
||||
bind:this={mobileNav}
|
||||
>
|
||||
<ul class="stack" role="list">
|
||||
{#each anchors as link (link.href)}
|
||||
<li><a href={link.href} class="mobile-link">{t(link.key)}</a></li>
|
||||
{/each}
|
||||
{#each routes as route (route.href)}
|
||||
<li>
|
||||
<a
|
||||
href={route.href}
|
||||
class="mobile-link route-link"
|
||||
style:--link-accent={route.accent}
|
||||
aria-current={pathname === route.href ? 'page' : undefined}
|
||||
>
|
||||
{t(route.key)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{#if canInstall()}
|
||||
<li>
|
||||
<button type="button" class="mobile-link install-mobile" onclick={() => promptInstall()}>
|
||||
{t('header.install')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
header {
|
||||
position: fixed;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 50;
|
||||
block-size: var(--header-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
transition:
|
||||
transform var(--duration-2) var(--ease-out),
|
||||
background-color var(--duration-2) var(--ease-out),
|
||||
border-color var(--duration-2) var(--ease-out);
|
||||
border-block-end: 1px solid transparent;
|
||||
}
|
||||
|
||||
header.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
header.scrolled {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-block-end-color: var(--glass-border);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 700;
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.route-link {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.route-link:hover,
|
||||
.route-link[aria-current='page'] {
|
||||
color: var(--link-accent);
|
||||
border-color: var(--link-accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-accent);
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
position: fixed;
|
||||
inset-block-start: var(--header-h);
|
||||
inset-inline: 0;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-block-end: 1px solid var(--glass-border);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-nav ul {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-1);
|
||||
font-weight: 500;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.mobile-link:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.install-mobile {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.desktop-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
/* HeroSection — plein viewport, shader océan en fond, titre + CTA. */
|
||||
import { stats } from '$lib/data';
|
||||
import HeroShader from './HeroShader.svelte';
|
||||
</script>
|
||||
|
||||
<section id="hero" aria-labelledby="hero-title">
|
||||
<HeroShader />
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<div class="content center stack">
|
||||
<h1 id="hero-title">
|
||||
Le réseau social,<br />
|
||||
<span class="gradient">sans les chaînes</span>
|
||||
</h1>
|
||||
|
||||
<p class="lede">
|
||||
Explorez le Fédiverse : une cartographie visuelle des
|
||||
<strong>{stats.totalSoftwares} logiciels</strong>
|
||||
qui redéfinissent la communication en ligne.
|
||||
</p>
|
||||
|
||||
<a href="#map" class="cta">
|
||||
Plonger dans la carte
|
||||
<svg
|
||||
class="chevron"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="scroll-indicator" aria-hidden="true">
|
||||
<span class="scroll-label">Scroll</span>
|
||||
<span class="scroll-line"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
/* Contexte sombre local : light-dark() bascule en mode sombre ici,
|
||||
quel que soit le thème de la page. */
|
||||
color-scheme: dark;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 100svh;
|
||||
overflow: hidden;
|
||||
background: var(--abyss);
|
||||
color: var(--on-accent);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
/* Voile de lisibilité : garantit le contraste du texte sur le shader */
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--abyss) 45%, transparent),
|
||||
color-mix(in oklab, var(--abyss) 25%, transparent) 45%,
|
||||
var(--abyss)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
--stack-gap: var(--space-4);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(var(--text-4), 4vw + 1.5rem, 4.5rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-inline-size: var(--measure);
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: var(--text-small);
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-accent);
|
||||
transition: transform var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
animation: bounce 1.6s var(--ease-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
inset-block-end: var(--space-4);
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.scroll-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.scroll-line {
|
||||
inline-size: 1px;
|
||||
block-size: var(--space-5);
|
||||
background: linear-gradient(180deg, var(--ink-soft), transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chevron {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
/* HeroShader — océan WebGL (Three.js, chargé paresseusement côté client).
|
||||
Remplit son parent en absolu ; purement décoratif (aria-hidden). */
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/* Palette cosinus type Iñigo Quilez : a + b*cos(6.28318*(c*t+d)) */
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 palette(in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d) {
|
||||
return a + b * cos(6.28318 * (c * t + d));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = (vUv - 0.5) * 2.0;
|
||||
uv.x *= u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv0 = uv;
|
||||
vec3 finalColor = vec3(0.0);
|
||||
|
||||
for (float i = 0.0; i < 3.0; i++) {
|
||||
uv = fract(uv * 1.5) - 0.5;
|
||||
|
||||
float d = length(uv) * exp(-length(uv0));
|
||||
|
||||
vec3 col = palette(length(uv0) + i * 0.4 + u_time * 0.4,
|
||||
vec3(0.5, 0.5, 0.9),
|
||||
vec3(0.5, 0.5, 0.4),
|
||||
vec3(1.0, 1.0, 0.9),
|
||||
vec3(0.0, 0.1, 0.2));
|
||||
|
||||
d = sin(d * 8.0 + u_time) / 8.0;
|
||||
d = abs(d);
|
||||
d = pow(0.01 / d, 1.2);
|
||||
|
||||
finalColor += col * d;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
(async () => {
|
||||
// Import paresseux obligatoire : rien de Three au top-level (SSR).
|
||||
const THREE = await import('three');
|
||||
if (disposed || !container) return;
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const width = container.offsetWidth || window.innerWidth;
|
||||
const height = container.offsetHeight || window.innerHeight;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 2000);
|
||||
camera.position.z = 1;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.domElement.style.width = '100%';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const uniforms = {
|
||||
u_time: { value: 0.0 },
|
||||
u_resolution: { value: new THREE.Vector2(width, height) }
|
||||
};
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(2, 2);
|
||||
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
uniforms.u_time.value += 0.008;
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (reduceMotion) {
|
||||
// Mouvement réduit : une seule frame statique (u_time fixe), pas de boucle.
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
// Frugalité : la boucle ne tourne que si le canvas est visible à l'écran
|
||||
// ET si l'onglet est actif.
|
||||
let inView = false;
|
||||
let pageVisible = !document.hidden;
|
||||
|
||||
const stop = () => {
|
||||
if (raf !== 0) {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
const sync = () => {
|
||||
if (inView && pageVisible) {
|
||||
if (raf === 0) raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
inView = entries[0]?.isIntersecting ?? false;
|
||||
sync();
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
observer.observe(container);
|
||||
|
||||
const onVisibility = () => {
|
||||
pageVisible = !document.hidden;
|
||||
sync();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = container.offsetHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
uniforms.u_resolution.value.set(w, h);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
cleanup = () => {
|
||||
stop();
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Chemin reduced-motion : pas d'observateurs d'animation, juste le resize.
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = container.offsetHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
uniforms.u_resolution.value.set(w, h);
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
cleanup = () => {
|
||||
resizeObserver.disconnect();
|
||||
renderer.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shader" bind:this={container} aria-hidden="true"></div>
|
||||
|
||||
<style>
|
||||
.shader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,797 @@
|
||||
<script lang="ts">
|
||||
/* Graphe 3D de l'écosystème — Three.js + d3-force.
|
||||
Les deux libs sont chargées paresseusement (await import dans onMount) :
|
||||
aucun octet de Three/D3 dans le bundle initial. Topologie déterministe
|
||||
issue de networkData (network.json pré-calculé, pas de Math.random de liens). */
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { categories, networkData, softwares } from '$lib/data';
|
||||
import type { NetworkNode, Software } from '$lib/data';
|
||||
import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from 'd3-force';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
type SimNode = NetworkNode & SimulationNodeDatum;
|
||||
type SimLink = SimulationLinkDatum<SimNode> & { type: 'strong' | 'weak' };
|
||||
|
||||
interface TooltipState {
|
||||
node: NetworkNode;
|
||||
software?: Software;
|
||||
x: number;
|
||||
y: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
let viewport = $state<HTMLDivElement | null>(null);
|
||||
// SSR / sans-JS : booting reste false → message de repli lisible (pas de
|
||||
// squelette éternel). onMount le passe à true avant de charger les chunks.
|
||||
let booting = $state(false);
|
||||
let loading = $state(true);
|
||||
let unavailable = $state(false);
|
||||
let tooltip = $state<TooltipState | null>(null);
|
||||
|
||||
let filters = $state<Record<string, boolean>>(
|
||||
Object.fromEntries(categories.map((c) => [c.id, true]))
|
||||
);
|
||||
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
|
||||
|
||||
// Assigné par onMount une fois les libs chargées.
|
||||
let rebuildGraph: (() => void) | null = null;
|
||||
|
||||
function toggleFilter(categoryId: string) {
|
||||
filters = { ...filters, [categoryId]: !filters[categoryId] };
|
||||
tooltip = null;
|
||||
rebuildGraph?.();
|
||||
}
|
||||
|
||||
function closeTooltip() {
|
||||
tooltip = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!viewport) return;
|
||||
booting = true;
|
||||
const container = viewport;
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let cancelled = false;
|
||||
let disposeGraph: (() => void) | null = null;
|
||||
let setPaused: ((paused: boolean) => void) | null = null;
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') tooltip = null;
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
|
||||
let ioPaused = false;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
ioPaused = !entries[0]?.isIntersecting;
|
||||
setPaused?.(ioPaused || document.hidden);
|
||||
},
|
||||
{ threshold: 0.05 }
|
||||
);
|
||||
const onVisibility = () => setPaused?.(document.hidden || ioPaused);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const THREE = await import('three');
|
||||
const d3 = await import('d3-force');
|
||||
if (cancelled) return;
|
||||
|
||||
// ————— Helpers couleurs : var(--…) → rgb utilisable par Three.js.
|
||||
// THREE.Color ne comprend ni oklch() ni light-dark() : conversion manuelle.
|
||||
const oklchToSrgb = (L: number, C: number, H: number): [number, number, number] => {
|
||||
const hRad = (H * Math.PI) / 180;
|
||||
const a = C * Math.cos(hRad);
|
||||
const b = C * Math.sin(hRad);
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
||||
const l = l_ ** 3;
|
||||
const m = m_ ** 3;
|
||||
const s = s_ ** 3;
|
||||
const to255 = (v: number): number => {
|
||||
const c = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
return Math.round(Math.min(Math.max(c, 0), 1) * 255);
|
||||
};
|
||||
return [
|
||||
to255(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
to255(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
to255(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
|
||||
];
|
||||
};
|
||||
|
||||
const toRgb = (color: string, fallback: string): string => {
|
||||
const oklchMatch = color.match(
|
||||
/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*[\d.]+%?)?\s*\)/
|
||||
);
|
||||
if (oklchMatch) {
|
||||
const lightness = parseFloat(oklchMatch[1]) / (oklchMatch[2] ? 100 : 1);
|
||||
const [r, g, b] = oklchToSrgb(
|
||||
lightness,
|
||||
parseFloat(oklchMatch[3]),
|
||||
parseFloat(oklchMatch[4])
|
||||
);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
if (/^(#|rgb)/.test(color)) return color;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const cssColor = (varName: string, fallback: string): string => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `var(${varName}, ${fallback})`;
|
||||
probe.style.display = 'none';
|
||||
container.appendChild(probe);
|
||||
const computed = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return toRgb(computed, fallback);
|
||||
};
|
||||
|
||||
const inkColor = cssColor('--ink', '#e8eef4');
|
||||
const lineColor = cssColor('--border-strong', '#8a97a8');
|
||||
|
||||
// ————— Scène persistante —————
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
container.offsetWidth / Math.max(container.offsetHeight, 1),
|
||||
0.1,
|
||||
2000
|
||||
);
|
||||
camera.position.z = 100;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(container.offsetWidth, container.offsetHeight);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.domElement.style.display = 'block';
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
|
||||
const directional = new THREE.DirectionalLight(0xffffff, 1.4);
|
||||
directional.position.set(40, 60, 80);
|
||||
scene.add(directional);
|
||||
|
||||
const graphGroup = new THREE.Group();
|
||||
scene.add(graphGroup);
|
||||
|
||||
// ————— État du sous-graphe courant —————
|
||||
let instancedMesh: InstanceType<typeof THREE.InstancedMesh> | null = null;
|
||||
let simulation: Simulation<SimNode, undefined> | null = null;
|
||||
let currentNodes: SimNode[] = [];
|
||||
const labelSprites = new Map<string, InstanceType<typeof THREE.Sprite>>();
|
||||
const linkLines: InstanceType<typeof THREE.Line>[] = [];
|
||||
const nodePositions = new Map<string, InstanceType<typeof THREE.Vector3>>();
|
||||
|
||||
const teardownSubgraph = () => {
|
||||
simulation?.stop();
|
||||
simulation = null;
|
||||
graphGroup.traverse((obj) => {
|
||||
if (obj instanceof THREE.InstancedMesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as import('three').Material).dispose();
|
||||
} else if (obj instanceof THREE.Sprite) {
|
||||
obj.material.map?.dispose();
|
||||
obj.material.dispose();
|
||||
} else if (obj instanceof THREE.Line) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as import('three').Material).dispose();
|
||||
}
|
||||
});
|
||||
graphGroup.clear();
|
||||
labelSprites.clear();
|
||||
linkLines.length = 0;
|
||||
nodePositions.clear();
|
||||
instancedMesh = null;
|
||||
currentNodes = [];
|
||||
};
|
||||
|
||||
const applyPositions = (dummy: InstanceType<typeof THREE.Object3D>) => {
|
||||
const mesh = instancedMesh;
|
||||
if (!mesh) return;
|
||||
currentNodes.forEach((node, i) => {
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
const z = node.z ?? 0;
|
||||
const scale = (node.val || 5) * 0.4;
|
||||
|
||||
dummy.position.set(x, y, z);
|
||||
dummy.scale.set(scale, scale, scale);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(i, dummy.matrix);
|
||||
|
||||
labelSprites.get(node.id)?.position.set(x, y + scale + 2, z);
|
||||
nodePositions.set(node.id, new THREE.Vector3(x, y, z));
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
|
||||
for (const line of linkLines) {
|
||||
const src = nodePositions.get(line.userData.source as string);
|
||||
const tgt = nodePositions.get(line.userData.target as string);
|
||||
if (!src || !tgt) continue;
|
||||
const attr = line.geometry.getAttribute(
|
||||
'position'
|
||||
) as import('three').BufferAttribute;
|
||||
const arr = attr.array as Float32Array;
|
||||
arr[0] = src.x; arr[1] = src.y; arr[2] = src.z;
|
||||
arr[3] = tgt.x; arr[4] = tgt.y; arr[5] = tgt.z;
|
||||
attr.needsUpdate = true;
|
||||
}
|
||||
};
|
||||
|
||||
const dummy = new THREE.Object3D();
|
||||
|
||||
const buildSubgraph = () => {
|
||||
teardownSubgraph();
|
||||
tooltip = null;
|
||||
|
||||
const activeCats = new Set(
|
||||
Object.entries(filters)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
);
|
||||
|
||||
currentNodes = networkData.nodes
|
||||
.filter((n) => activeCats.has(n.category))
|
||||
.map((n) => ({
|
||||
...n,
|
||||
x: (Math.random() - 0.5) * 60,
|
||||
y: (Math.random() - 0.5) * 60,
|
||||
z: (Math.random() - 0.5) * 30
|
||||
}));
|
||||
|
||||
if (currentNodes.length === 0) {
|
||||
renderer.render(scene, camera);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeIds = new Set(currentNodes.map((n) => n.id));
|
||||
const links: SimLink[] = networkData.links
|
||||
.filter(
|
||||
(l) =>
|
||||
nodeIds.has(l.source as string) && nodeIds.has(l.target as string)
|
||||
)
|
||||
.map((l) => ({
|
||||
source: l.source as string,
|
||||
target: l.target as string,
|
||||
type: l.type
|
||||
}));
|
||||
|
||||
// Nœuds (instanciés)
|
||||
const geometry = new THREE.SphereGeometry(1, 16, 16);
|
||||
const material = new THREE.MeshPhysicalMaterial({
|
||||
color: 0xffffff,
|
||||
metalness: 0.1,
|
||||
roughness: 0.3,
|
||||
clearcoat: 1.0,
|
||||
clearcoatRoughness: 0.1
|
||||
});
|
||||
instancedMesh = new THREE.InstancedMesh(geometry, material, currentNodes.length);
|
||||
instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
currentNodes.forEach((node, i) => {
|
||||
instancedMesh!.setColorAt(i, new THREE.Color(node.color || '#888888'));
|
||||
});
|
||||
if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true;
|
||||
graphGroup.add(instancedMesh);
|
||||
|
||||
// Labels (un canvas par sprite — le partage de canvas entre
|
||||
// CanvasTexture afficherait le dernier label partout)
|
||||
for (const node of currentNodes) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.font = '500 24px "Space Grotesk", sans-serif';
|
||||
ctx.fillStyle = inkColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(node.id, 128, 32);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const spriteMaterial = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.85
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.scale.set(12, 3, 1);
|
||||
graphGroup.add(sprite);
|
||||
labelSprites.set(node.id, sprite);
|
||||
}
|
||||
|
||||
// Liens
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(lineColor),
|
||||
transparent: true,
|
||||
opacity: 0.3
|
||||
});
|
||||
for (const link of links) {
|
||||
const positions = new Float32Array(6);
|
||||
const lineGeometry = new THREE.BufferGeometry();
|
||||
lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial);
|
||||
line.userData = {
|
||||
source: link.source,
|
||||
target: link.target
|
||||
};
|
||||
graphGroup.add(line);
|
||||
linkLines.push(line);
|
||||
}
|
||||
|
||||
// Simulation (2D, z statique — parité avec la version React)
|
||||
simulation = d3
|
||||
.forceSimulation<SimNode>(currentNodes)
|
||||
.force(
|
||||
'link',
|
||||
d3
|
||||
.forceLink<SimNode, SimLink>(links)
|
||||
.id((d) => d.id)
|
||||
.distance((d) => (d.type === 'strong' ? 15 : 35))
|
||||
)
|
||||
.force('charge', d3.forceManyBody().strength(-100))
|
||||
.force('center', d3.forceCenter(0, 0))
|
||||
.force(
|
||||
'collide',
|
||||
d3
|
||||
.forceCollide<SimNode>()
|
||||
.radius((d) => (d.group === 1 ? 6 : 3) + 2)
|
||||
.iterations(2)
|
||||
)
|
||||
.force('x', d3.forceX(0).strength(0.04))
|
||||
.force('y', d3.forceY(0).strength(0.04))
|
||||
.alphaDecay(0.02);
|
||||
|
||||
if (reducedMotion) {
|
||||
// Convergence synchrone, une seule frame rendue.
|
||||
simulation.stop();
|
||||
for (let i = 0; i < 300; i++) simulation.tick();
|
||||
applyPositions(dummy);
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
simulation.on('tick', () => applyPositions(dummy));
|
||||
}
|
||||
};
|
||||
|
||||
// ————— Interactions —————
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
|
||||
const pick = (clientX: number, clientY: number): SimNode | null => {
|
||||
if (!instancedMesh) return null;
|
||||
const rect = container.getBoundingClientRect();
|
||||
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hits = raycaster.intersectObject(instancedMesh);
|
||||
const id = hits[0]?.instanceId;
|
||||
return id !== undefined ? (currentNodes[id] ?? null) : null;
|
||||
};
|
||||
|
||||
const showTooltip = (node: SimNode, clientX: number, clientY: number, pinned: boolean) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
tooltip = {
|
||||
node,
|
||||
software: softwares.find((s) => s.name === node.id),
|
||||
x: Math.min(clientX - rect.left + 20, rect.width - 280),
|
||||
y: Math.min(clientY - rect.top + 20, rect.height - 220),
|
||||
pinned
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'mouse' || tooltip?.pinned) return;
|
||||
const node = pick(event.clientX, event.clientY);
|
||||
if (node) {
|
||||
showTooltip(node, event.clientX, event.clientY, false);
|
||||
container.style.cursor = 'pointer';
|
||||
} else {
|
||||
tooltip = null;
|
||||
container.style.cursor = 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const node = pick(event.clientX, event.clientY);
|
||||
if (node) {
|
||||
showTooltip(node, event.clientX, event.clientY, true);
|
||||
} else {
|
||||
tooltip = null;
|
||||
}
|
||||
};
|
||||
|
||||
// ————— Boucle de rendu & pause —————
|
||||
let rafId = 0;
|
||||
let paused = false;
|
||||
|
||||
const animate = () => {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
setPaused = (next: boolean) => {
|
||||
if (reducedMotion) return;
|
||||
if (next === paused) return;
|
||||
paused = next;
|
||||
if (paused) {
|
||||
cancelAnimationFrame(rafId);
|
||||
} else {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.offsetWidth;
|
||||
const h = Math.max(container.offsetHeight, 1);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
if (reducedMotion || paused) renderer.render(scene, camera);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
container.addEventListener('pointermove', onPointerMove);
|
||||
container.addEventListener('click', onClick);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
buildSubgraph();
|
||||
rebuildGraph = buildSubgraph;
|
||||
loading = false;
|
||||
|
||||
if (!reducedMotion) animate();
|
||||
|
||||
disposeGraph = () => {
|
||||
setPaused = null;
|
||||
rebuildGraph = null;
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
container.removeEventListener('pointermove', onPointerMove);
|
||||
container.removeEventListener('click', onClick);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
teardownSubgraph();
|
||||
renderer.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
unavailable = true;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
io.observe(container);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
io.disconnect();
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
disposeGraph?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="map" aria-labelledby="map-title">
|
||||
<div class="center section-head stack">
|
||||
<p class="kicker">Cartographie</p>
|
||||
<h2 id="map-title">L'Écosystème</h2>
|
||||
<p class="subtitle">
|
||||
{activeCount} catégories actives · Survolez ou touchez un nœud pour plus d'infos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="graph-wrap center">
|
||||
<div
|
||||
class="viewport"
|
||||
bind:this={viewport}
|
||||
role="img"
|
||||
aria-label="Graphe 3D des 105 logiciels du Fédiverse, colorés par catégorie. Le détail de chaque logiciel est aussi disponible dans le catalogue."
|
||||
>
|
||||
{#if !booting}
|
||||
<div class="fallback stack">
|
||||
<p>La carte 3D interactive nécessite JavaScript.</p>
|
||||
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="skeleton" role="status">
|
||||
<span>Chargement de la carte…</span>
|
||||
</div>
|
||||
{:else if unavailable}
|
||||
<div class="fallback stack">
|
||||
<p>La carte 3D n'est pas disponible dans ce navigateur.</p>
|
||||
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<aside class="filters" aria-label="Filtres par catégorie">
|
||||
<h3>Filtres</h3>
|
||||
<ul class="stack" role="list">
|
||||
{#each categories as cat (cat.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
class:inactive={!filters[cat.id]}
|
||||
aria-pressed={filters[cat.id]}
|
||||
style:--cat-color={cat.color}
|
||||
onclick={() => toggleFilter(cat.id)}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="cat-name">{cat.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{#if tooltip}
|
||||
<div
|
||||
class="tooltip"
|
||||
style:left="{tooltip.x}px"
|
||||
style:top="{tooltip.y}px"
|
||||
role={tooltip.pinned ? 'dialog' : 'status'}
|
||||
aria-label={tooltip.pinned ? `Détails de ${tooltip.node.id}` : undefined}
|
||||
>
|
||||
<div class="tooltip-head">
|
||||
<span class="dot glow" style:--cat-color={tooltip.node.color} aria-hidden="true"></span>
|
||||
<h4>{tooltip.node.id}</h4>
|
||||
{#if tooltip.pinned}
|
||||
<button type="button" class="tooltip-close" onclick={closeTooltip} aria-label={t('common.close')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="tooltip-desc">{tooltip.node.description}</p>
|
||||
<dl class="tooltip-meta mono">
|
||||
<div><dt>Licence</dt><dd>{tooltip.node.licence}</dd></div>
|
||||
{#if tooltip.software}
|
||||
<div><dt>Langage</dt><dd>{tooltip.software.language}</dd></div>
|
||||
{/if}
|
||||
<div><dt>État</dt><dd>{tooltip.node.etat}</dd></div>
|
||||
<div><dt>ActivityPub</dt><dd>{tooltip.node.compatibiliteAP}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--surface);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
--stack-gap: var(--space-2);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.graph-wrap {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
block-size: clamp(30rem, 85vh, 56rem);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fallback-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ————— Panneau de filtres ————— */
|
||||
.filters {
|
||||
margin-block-start: var(--space-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.filters h3 {
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
--stack-gap: var(--space-1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-2);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.filter-btn .dot {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cat-color);
|
||||
box-shadow: 0 0 0.5rem var(--cat-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .dot {
|
||||
background: var(--muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .cat-name {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
@container (min-width: 56rem) {
|
||||
.filters {
|
||||
position: absolute;
|
||||
inset-block-start: var(--space-3);
|
||||
inset-inline-end: var(--space-3);
|
||||
margin-block-start: 0;
|
||||
max-inline-size: 15rem;
|
||||
}
|
||||
|
||||
.filters ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— Tooltip / panneau détail ————— */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
max-inline-size: 17rem;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
box-shadow: var(--shadow-2);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.tooltip-head h4 {
|
||||
font-size: var(--text-1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dot.glow {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cat-color);
|
||||
box-shadow: 0 0 0.5rem var(--cat-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tooltip-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tooltip-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.tooltip-meta {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.tooltip-meta div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tooltip-meta dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tooltip-meta dd {
|
||||
color: var(--ink);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
|
||||
let online = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
online = navigator.onLine;
|
||||
const handleOnline = () => (online = true);
|
||||
const handleOffline = () => (online = false);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !online}
|
||||
<div class="offline-pill" role="status">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('offline.indicator')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.offline-pill {
|
||||
position: fixed;
|
||||
inset-block-end: var(--space-3);
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-small);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--status-maintenance);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
/* ProtocolSection — grille des 12 protocoles sur fond « data stream ». */
|
||||
import { protocols, type Protocol } from '$lib/data';
|
||||
import ProtocolStream from './ProtocolStream.svelte';
|
||||
|
||||
const TYPES: Record<Protocol['type'], { label: string; color: string }> = {
|
||||
fediverse: { label: 'Protocole du Fédiverse', color: 'var(--accent-4-text)' },
|
||||
connexe: { label: 'Protocole connexe', color: 'var(--status-maintenance)' },
|
||||
voisin: { label: 'Écosystème voisin', color: 'var(--accent-text)' }
|
||||
};
|
||||
|
||||
const TYPE_ORDER: Protocol['type'][] = ['fediverse', 'connexe', 'voisin'];
|
||||
|
||||
/* Couleur doublée par le texte du statut (jamais la couleur seule) */
|
||||
const STATUS_COLORS: Record<Protocol['statut'], string> = {
|
||||
Actif: 'var(--status-active)',
|
||||
'En développement': 'var(--status-experimental)',
|
||||
Historique: 'var(--status-historical)'
|
||||
};
|
||||
</script>
|
||||
|
||||
<section id="protocols" aria-labelledby="protocols-title">
|
||||
<ProtocolStream />
|
||||
<div class="veil" aria-hidden="true"></div>
|
||||
|
||||
<div class="content center stack">
|
||||
<header class="section-head stack">
|
||||
<p class="kicker">Standards & Protocoles</p>
|
||||
<h2 id="protocols-title">Les fondations du <span class="gradient">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
Comprendre les protocoles qui permettent la communication décentralisée entre les
|
||||
différentes plateformes.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid cards" role="list">
|
||||
{#each protocols as proto (proto.id)}
|
||||
<li>
|
||||
<article class="card" style:--type-color={TYPES[proto.type].color}>
|
||||
<div class="card-inner">
|
||||
<div class="card-media">
|
||||
<span class="icon-box">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if proto.id === 'activitypub'}
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
{:else if proto.id === 'activitystreams'}
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
|
||||
{:else if proto.id === 'webfinger'}
|
||||
<path d="M2 9.5a15 15 0 0 1 20 0" />
|
||||
<path d="M5 13a10 10 0 0 1 14 0" />
|
||||
<path d="M8.5 16.5a5 5 0 0 1 7 0" />
|
||||
<path d="M12 20h.01" />
|
||||
{:else if proto.id === 'httpsignatures'}
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
{:else if proto.id === 'nodeinfo'}
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" />
|
||||
<path d="M6 6h.01" />
|
||||
<path d="M6 18h.01" />
|
||||
{:else if proto.id === 'zot'}
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
{:else if proto.id === 'diaspora'}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
{:else if proto.id === 'ostatus'}
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
{:else if proto.id === 'matrix'}
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
{:else if proto.id === 'xmpp'}
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M4.93 19.07a10 10 0 0 1 0-14.14" />
|
||||
<path d="M7.76 16.24a6 6 0 0 1 0-8.49" />
|
||||
<path d="M16.24 7.76a6 6 0 0 1 0 8.49" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
{:else if proto.id === 'atproto'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="m10 13-2 2 2 2" />
|
||||
<path d="m14 17 2-2-2-2" />
|
||||
{:else if proto.id === 'nostr'}
|
||||
<circle cx="7.5" cy="15.5" r="5.5" />
|
||||
<path d="m21 2-9.6 9.6" />
|
||||
<path d="m15.5 7.5 3 3L22 7l-3-3" />
|
||||
{:else}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body stack">
|
||||
<div class="card-head cluster">
|
||||
<h3>{proto.name}</h3>
|
||||
<span class="badge">{TYPES[proto.type].label}</span>
|
||||
<span class="status" style:--status-color={STATUS_COLORS[proto.statut]}>
|
||||
<span class="dot" aria-hidden="true"></span>{proto.statut}
|
||||
</span>
|
||||
</div>
|
||||
<p class="description">{proto.description}</p>
|
||||
{#if proto.rfc}
|
||||
<span class="rfc">{proto.rfc}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ul class="legend cluster" role="list">
|
||||
{#each TYPE_ORDER as type (type)}
|
||||
<li class="cluster" style:--type-color={TYPES[type].color}>
|
||||
<span class="dot" aria-hidden="true"></span>{TYPES[type].label}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
/* Contexte sombre local : light-dark() bascule en mode sombre ici. */
|
||||
color-scheme: dark;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--ocean);
|
||||
color: var(--on-accent);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
/* Voile de lisibilité au-dessus du data stream */
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--ocean) 70%, transparent),
|
||||
color-mix(in oklab, var(--ocean) 55%, transparent)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
--stack-gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
--stack-gap: var(--space-2);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent-2-text);
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-inline-size: var(--measure);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.cards {
|
||||
--grid-min: 20rem;
|
||||
--grid-gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
container-type: inline-size;
|
||||
block-size: 100%;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(0.5rem);
|
||||
-webkit-backdrop-filter: blur(0.5rem);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-3);
|
||||
transition:
|
||||
transform var(--duration-2) var(--ease-out),
|
||||
border-color var(--duration-2) var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--type-color);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* Container query : carte large → layout horizontal (icône à gauche) */
|
||||
@container (min-width: 28rem) {
|
||||
.card-inner {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.card-media {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--type-color);
|
||||
background: color-mix(in oklab, var(--type-color) 12%, transparent);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
--stack-gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
--cluster-gap: var(--space-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--type-color);
|
||||
background: color-mix(in oklab, var(--type-color) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--type-color) 30%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--status-color, var(--type-color));
|
||||
box-shadow: 0 0 0.375rem var(--status-color, var(--type-color));
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.rfc {
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
background: color-mix(in oklab, var(--on-accent) 6%, transparent);
|
||||
border-radius: var(--radius-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.legend {
|
||||
--cluster-gap: var(--space-4);
|
||||
justify-content: center;
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.legend .dot {
|
||||
background: var(--type-color);
|
||||
box-shadow: 0 0 0.375rem var(--type-color);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
/* ProtocolStream — colonnes de « data stream » défilant en boucle,
|
||||
purement décoratives (aria-hidden). Aucune animation si mouvement réduit. */
|
||||
|
||||
const LINES = [
|
||||
'ActivityPub v1.0',
|
||||
'{ "type": "Create", "actor": "https://..." }',
|
||||
'W3C Recommendation',
|
||||
'WebFinger RFC 7033',
|
||||
'HTTP Signatures',
|
||||
'NodeInfo 2.0',
|
||||
'ActivityStreams 2.0',
|
||||
'application/activity+json',
|
||||
'followers_collection',
|
||||
'inbox → outbox',
|
||||
'FEP-8b32: Long Objects',
|
||||
'FEP-5624: Per-Event Push',
|
||||
'@user@instance.social',
|
||||
'fediverse:culture',
|
||||
'2026-01-14T09:30:00Z',
|
||||
'public_key: RSA 2048',
|
||||
'LD-Signatures',
|
||||
'content-type: application/ld+json',
|
||||
'as:Note, as:Article',
|
||||
'sharedInbox enabled'
|
||||
] as const;
|
||||
|
||||
/* Contenu doublé pour une boucle seamless (translateY(-50%) → 0) */
|
||||
const DOUBLED = [...LINES, ...LINES];
|
||||
|
||||
const COLUMNS = [
|
||||
{ left: '-10%', duration: '20s', delay: '0s' },
|
||||
{ left: '15%', duration: '28s', delay: '3s' },
|
||||
{ left: '40%', duration: '24s', delay: '7s' },
|
||||
{ left: '65%', duration: '32s', delay: '1s' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="stream" aria-hidden="true">
|
||||
{#each COLUMNS as col (col.left)}
|
||||
<div
|
||||
class="column"
|
||||
style:--left={col.left}
|
||||
style:--duration={col.duration}
|
||||
style:--delay={col.delay}
|
||||
>
|
||||
{#each DOUBLED as line, i (i)}
|
||||
<span class="line">{line}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stream {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: var(--left);
|
||||
inline-size: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--accent);
|
||||
opacity: 0.3;
|
||||
animation: stream-flow var(--duration) linear infinite;
|
||||
animation-delay: var(--delay);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.line {
|
||||
white-space: nowrap;
|
||||
padding-inline: var(--space-2);
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
@keyframes stream-flow {
|
||||
from {
|
||||
transform: translateY(-50%) skewX(-20deg);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0) skewX(-20deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.column {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { isTypingTarget } from '$lib/keyboard';
|
||||
|
||||
let open = $state(false);
|
||||
let dialogEl = $state<HTMLElement | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && open) {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
if (event.key === '?' && !isTypingTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
open = !open;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
dialogEl?.querySelector<HTMLElement>('button')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['/'], descKey: 'shortcuts.search' },
|
||||
{ keys: ['?'], descKey: 'shortcuts.help' },
|
||||
{ keys: ['j'], descKey: 'shortcuts.next' },
|
||||
{ keys: ['k'], descKey: 'shortcuts.prev' },
|
||||
{ keys: ['Échap'], descKey: 'shortcuts.close' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="backdrop"
|
||||
onclick={() => (open = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (open = false)}
|
||||
role="presentation"
|
||||
></div>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-title"
|
||||
bind:this={dialogEl}
|
||||
>
|
||||
<div class="dialog-header cluster">
|
||||
<h2 id="shortcuts-title">{t('shortcuts.title')}</h2>
|
||||
<button type="button" class="close-btn" onclick={() => (open = false)} aria-label={t('common.close')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="stack" role="list">
|
||||
{#each shortcuts as shortcut (shortcut.descKey)}
|
||||
<li class="shortcut-row">
|
||||
<span class="cluster keys">
|
||||
{#each shortcut.keys as key (key)}
|
||||
<kbd>{key}</kbd>
|
||||
{/each}
|
||||
</span>
|
||||
<span>{t(shortcut.descKey)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: oklch(10% 0.02 260 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
z-index: 90;
|
||||
inline-size: min(26rem, calc(100vw - 2 * var(--space-3)));
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
justify-content: space-between;
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.dialog ul {
|
||||
--stack-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: 2rem;
|
||||
min-block-size: 2rem;
|
||||
padding-inline: var(--space-2);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-block-end-width: 2px;
|
||||
border-radius: var(--radius-1);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,848 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { categoryById, ETATS, softwares, type Category, type Software } from '$lib/data';
|
||||
import { t } from '$lib/i18n/i18n.svelte';
|
||||
import { isTypingTarget } from '$lib/keyboard';
|
||||
|
||||
type StatusFilter = Software['etat'] | 'all';
|
||||
|
||||
const STORAGE_KEY = 'atlas-catalog-filters';
|
||||
|
||||
const statusColor: Record<Software['etat'], string> = {
|
||||
Actif: 'var(--status-active)',
|
||||
Maintenance: 'var(--status-maintenance)',
|
||||
Experimental: 'var(--status-experimental)',
|
||||
Historique: 'var(--status-historical)'
|
||||
};
|
||||
|
||||
const compatColor: Record<Software['compatibiliteAP'], string> = {
|
||||
Pleine: 'var(--compat-full)',
|
||||
Partielle: 'var(--compat-partial)',
|
||||
Extension: 'var(--compat-extension)',
|
||||
Aucune: 'var(--compat-none)',
|
||||
'En développement': 'var(--compat-none)'
|
||||
};
|
||||
|
||||
/** Choisit un texte contrasté (clair ou foncé) selon la luminance WCAG d'une couleur hex. */
|
||||
function contrastingInk(hex: string | undefined): string {
|
||||
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return 'var(--on-accent)';
|
||||
const channels = [1, 3, 5].map((start) => {
|
||||
const c = parseInt(hex.slice(start, start + 2), 16) / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
const luminance = 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
return luminance > 0.18 ? 'oklch(22% 0.02 260)' : 'var(--on-accent)';
|
||||
}
|
||||
|
||||
function categoryOf(software: Software): Category | undefined {
|
||||
return categoryById.get(software.category);
|
||||
}
|
||||
|
||||
function categoryName(software: Software): string {
|
||||
return categoryOf(software)?.name ?? software.category;
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let activeCategory = $state('all');
|
||||
let activeStatus = $state<StatusFilter>('all');
|
||||
let selectedSoftware = $state<Software | null>(null);
|
||||
|
||||
let searchInput = $state<HTMLInputElement | null>(null);
|
||||
let gridEl = $state<HTMLElement | null>(null);
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let triggerElement: HTMLElement | null = null;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const result = softwares.filter((software) => {
|
||||
if (activeCategory !== 'all' && software.category !== activeCategory) return false;
|
||||
if (activeStatus !== 'all' && software.etat !== activeStatus) return false;
|
||||
if (query) {
|
||||
const haystack =
|
||||
`${software.name} ${software.description} ${software.category} ${categoryName(software)} ${software.language}`.toLowerCase();
|
||||
if (!haystack.includes(query)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
result.sort((a, b) => {
|
||||
const statusDiff = ETATS.indexOf(a.etat) - ETATS.indexOf(b.etat);
|
||||
return statusDiff !== 0 ? statusDiff : a.name.localeCompare(b.name);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
function resetFilters(): void {
|
||||
search = '';
|
||||
activeCategory = 'all';
|
||||
activeStatus = 'all';
|
||||
}
|
||||
|
||||
function openDetails(software: Software, trigger: HTMLElement | null): void {
|
||||
triggerElement = trigger;
|
||||
selectedSoftware = software;
|
||||
}
|
||||
|
||||
function closeDetails(): void {
|
||||
selectedSoftware = null;
|
||||
triggerElement?.focus();
|
||||
triggerElement = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Reprennabilité : restaure les filtres de la session
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof saved.search === 'string') search = saved.search;
|
||||
if (
|
||||
saved.activeCategory === 'all' ||
|
||||
(typeof saved.activeCategory === 'string' && categoryById.has(saved.activeCategory))
|
||||
) {
|
||||
activeCategory = saved.activeCategory as string;
|
||||
}
|
||||
if (
|
||||
saved.activeStatus === 'all' ||
|
||||
(typeof saved.activeStatus === 'string' &&
|
||||
(ETATS as readonly string[]).includes(saved.activeStatus))
|
||||
) {
|
||||
activeStatus = saved.activeStatus as StatusFilter;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stockage indisponible ou données corrompues : on ignore */
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (selectedSoftware) closeDetails();
|
||||
return;
|
||||
}
|
||||
if (selectedSoftware) return;
|
||||
if (isTypingTarget(event.target) || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
|
||||
if (event.key === '/') {
|
||||
event.preventDefault();
|
||||
searchInput?.focus();
|
||||
} else if (event.key === 'j' || event.key === 'k') {
|
||||
const cards = gridEl ? [...gridEl.querySelectorAll<HTMLElement>('.card')] : [];
|
||||
if (cards.length === 0) return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'j' ? 1 : -1;
|
||||
const current = cards.indexOf(document.activeElement as HTMLElement);
|
||||
const next =
|
||||
current === -1
|
||||
? delta === 1
|
||||
? 0
|
||||
: cards.length - 1
|
||||
: (current + delta + cards.length) % cards.length;
|
||||
cards[next].focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
// Persiste les filtres pour la session
|
||||
$effect(() => {
|
||||
const snapshot = { search, activeCategory, activeStatus };
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
/* stockage indisponible : on ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Focus sur le bouton fermer à l'ouverture de la modale
|
||||
$effect(() => {
|
||||
if (selectedSoftware) closeButton?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="catalog" aria-labelledby="catalog-title">
|
||||
<div class="inner center">
|
||||
<header class="section-header stack">
|
||||
<p class="kicker">Répertoire</p>
|
||||
<h2 id="catalog-title">Les logiciels du <span class="gradient-text">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
{softwares.length} logiciels référencés, classés par catégorie et filtrables par statut
|
||||
d'activité.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-field">
|
||||
<label class="visually-hidden" for="catalog-search">{t('common.search')}</label>
|
||||
<svg
|
||||
class="search-icon"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
|
||||
<path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
id="catalog-search"
|
||||
type="search"
|
||||
bind:this={searchInput}
|
||||
bind:value={search}
|
||||
placeholder="Rechercher un logiciel, un langage…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if search}
|
||||
<button
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
aria-label={t('common.clear')}
|
||||
onclick={() => {
|
||||
search = '';
|
||||
searchInput?.focus();
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="selects cluster">
|
||||
<label class="select-label">
|
||||
<span class="visually-hidden">Filtrer par catégorie</span>
|
||||
<select bind:value={activeCategory}>
|
||||
<option value="all">Toutes les catégories</option>
|
||||
{#each [...categoryById.values()] as category (category.id)}
|
||||
<option value={category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="select-label">
|
||||
<span class="visually-hidden">Filtrer par statut</span>
|
||||
<select bind:value={activeStatus}>
|
||||
<option value="all">Tous les statuts</option>
|
||||
{#each ETATS as etat (etat)}
|
||||
<option value={etat}>{etat}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="results-count" role="status">
|
||||
{t('catalog.results', { count: filtered.length })}<!--
|
||||
-->{#if activeCategory !== 'all'}
|
||||
dans <strong>{categoryById.get(activeCategory)?.name ?? activeCategory}</strong>{/if}<!--
|
||||
-->{#if activeStatus !== 'all'}
|
||||
· statut : <strong>{activeStatus}</strong>{/if}<!--
|
||||
-->{#if search.trim()}
|
||||
pour « <strong>{search.trim()}</strong> »{/if}
|
||||
</p>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty stack">
|
||||
<p>{t('common.emptyResults')}</p>
|
||||
<button type="button" class="reset-btn" onclick={resetFilters}>
|
||||
{t('common.resetFilters')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid catalog-grid" bind:this={gridEl}>
|
||||
{#each filtered as software (software.id)}
|
||||
{@const category = categoryOf(software)}
|
||||
<button
|
||||
type="button"
|
||||
class="card"
|
||||
style:--cat-color={category?.color ?? 'var(--muted)'}
|
||||
style:--tile-ink={contrastingInk(category?.color)}
|
||||
style:--status-color={statusColor[software.etat]}
|
||||
style:--compat-color={compatColor[software.compatibiliteAP]}
|
||||
aria-haspopup="dialog"
|
||||
onclick={(event) => openDetails(software, event.currentTarget)}
|
||||
>
|
||||
<span class="card-inner">
|
||||
<span class="tile" aria-hidden="true">{software.name.charAt(0)}</span>
|
||||
<span class="card-body">
|
||||
<span class="card-head">
|
||||
<span class="card-name">{software.name}</span>
|
||||
<span class="card-cat">{categoryName(software)}</span>
|
||||
</span>
|
||||
<span class="status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
{software.etat}
|
||||
</span>
|
||||
<span class="card-desc">{software.description}</span>
|
||||
<span class="chips">
|
||||
<span class="chip">{software.language}</span>
|
||||
<span class="chip compat">AP : {software.compatibiliteAP}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="end-marker">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
<path d="M12 8v13" />
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3" />
|
||||
</svg>
|
||||
{t('catalog.upToDate')} · {t('catalog.upToDateCreole')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if selectedSoftware}
|
||||
{@const software = selectedSoftware}
|
||||
{@const category = categoryOf(software)}
|
||||
<div class="backdrop" onclick={closeDetails} role="presentation"></div>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="software-dialog-title"
|
||||
style:--cat-color={category?.color ?? 'var(--muted)'}
|
||||
style:--tile-ink={contrastingInk(category?.color)}
|
||||
style:--status-color={statusColor[software.etat]}
|
||||
style:--compat-color={compatColor[software.compatibiliteAP]}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
bind:this={closeButton}
|
||||
aria-label={t('common.close')}
|
||||
onclick={closeDetails}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="dialog-head">
|
||||
<span class="tile tile-lg" aria-hidden="true">{software.name.charAt(0)}</span>
|
||||
<span class="dialog-title-block">
|
||||
<h3 id="software-dialog-title">{software.name}</h3>
|
||||
<span class="badges">
|
||||
<span class="chip cat-chip">{categoryName(software)}</span>
|
||||
<span class="status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
{software.etat}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="dialog-desc">{software.description}</p>
|
||||
|
||||
<dl class="details">
|
||||
<div class="detail-row">
|
||||
<dt>Protocoles</dt>
|
||||
<dd>{software.protocols.join(', ')}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>Licence</dt>
|
||||
<dd>{software.licence}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>Langage</dt>
|
||||
<dd>{software.language}</dd>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<dt>ActivityPub</dt>
|
||||
<dd class="compat-value">{software.compatibiliteAP}</dd>
|
||||
</div>
|
||||
{#if software.version}
|
||||
<div class="detail-row">
|
||||
<dt>Version</dt>
|
||||
<dd>{software.version}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if software.createdYear}
|
||||
<div class="detail-row">
|
||||
<dt>Créé en</dt>
|
||||
<dd>{software.createdYear}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="dialog-actions">
|
||||
{#if software.site}
|
||||
<a href={software.site} target="_blank" rel="external noopener" class="btn-primary">
|
||||
{t('common.officialSite')}
|
||||
</a>
|
||||
{/if}
|
||||
{#if software.github}
|
||||
<a href={software.github} target="_blank" rel="external noopener" class="btn-outline">
|
||||
{t('common.sourceCode')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--surface);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--ink-soft);
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
/* --- Barre d'outils --- */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-block-end: var(--space-3);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
flex: 1 1 18rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--space-3);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline-start: calc(var(--space-3) * 2 + 1.125rem);
|
||||
padding-inline-end: var(--tap-target);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--ink);
|
||||
transition: border-color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.search-field input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-field input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--space-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: calc(var(--tap-target) - var(--space-2));
|
||||
block-size: calc(var(--tap-target) - var(--space-2));
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.selects {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.select-label select {
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-label select:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Compteur --- */
|
||||
|
||||
.results-count {
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.results-count strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* --- État vide --- */
|
||||
|
||||
.empty {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding-block: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--accent-text);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
/* --- Grille et cartes --- */
|
||||
|
||||
.catalog-grid {
|
||||
--grid-min: 19rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
container-type: inline-size;
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color var(--duration-1) var(--ease-out),
|
||||
box-shadow var(--duration-1) var(--ease-out),
|
||||
transform var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: color-mix(in oklch, var(--cat-color) 55%, var(--glass-border));
|
||||
box-shadow: var(--shadow-2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.75rem;
|
||||
block-size: 2.75rem;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--cat-color);
|
||||
color: var(--tile-ink);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.card-cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
inline-size: 0.625rem;
|
||||
block-size: 0.625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--status-color);
|
||||
box-shadow: 0 0 0.4rem color-mix(in oklch, var(--status-color) 60%, transparent);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.chip.compat {
|
||||
background: color-mix(in oklch, var(--compat-color) 14%, transparent);
|
||||
border-color: color-mix(in oklch, var(--compat-color) 45%, transparent);
|
||||
color: var(--compat-color);
|
||||
}
|
||||
|
||||
/* Container query : tuile au-dessus quand la carte est étroite */
|
||||
@container (max-width: 15rem) {
|
||||
.card-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Marqueur de fin (pas de scroll infini) --- */
|
||||
|
||||
.end-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-5);
|
||||
padding-block-start: var(--space-4);
|
||||
border-block-start: 1px solid var(--border);
|
||||
font-size: var(--text-small);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* --- Modale détail --- */
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: oklch(10% 0.02 260 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
z-index: 90;
|
||||
inline-size: min(32rem, calc(100vw - 2 * var(--space-3)));
|
||||
max-block-size: 85dvh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
inset-block-start: var(--space-2);
|
||||
inset-inline-end: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-block-end: var(--space-3);
|
||||
padding-inline-end: var(--tap-target);
|
||||
}
|
||||
|
||||
.tile-lg {
|
||||
inline-size: 3.5rem;
|
||||
block-size: 3.5rem;
|
||||
font-size: var(--text-3);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.dialog-title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
background: color-mix(in oklch, var(--cat-color) 16%, transparent);
|
||||
border-color: color-mix(in oklch, var(--cat-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.dialog-desc {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.details {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-2);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-row dt {
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-row dd {
|
||||
color: var(--ink);
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compat-value {
|
||||
color: var(--compat-color);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.dialog-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border-radius: var(--radius-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1 1 auto;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { networkData, stats } from '$lib/data';
|
||||
import { timelineEvents, type TimelineEvent } from '$lib/data/timeline';
|
||||
|
||||
type CategoryKey = TimelineEvent['category'];
|
||||
type FilterKey = CategoryKey | 'all';
|
||||
|
||||
/* Couleurs de catégories : tokens sémantiques (adaptés au thème clair/sombre). */
|
||||
const categoryConfig = {
|
||||
foundation: { label: 'Fondation', color: 'var(--muted)' },
|
||||
protocol: { label: 'Protocole', color: 'var(--accent)' },
|
||||
software: { label: 'Logiciel', color: 'var(--accent-2)' },
|
||||
milestone: { label: 'Jalon', color: 'var(--status-maintenance)' },
|
||||
ecosystem: { label: 'Écosystème', color: 'var(--accent-4)' }
|
||||
} as const satisfies Record<CategoryKey, { label: string; color: string }>;
|
||||
|
||||
const categoryFilters: readonly { key: FilterKey; label: string; color: string }[] = [
|
||||
{ key: 'all', label: 'Tout', color: 'var(--accent)' },
|
||||
{ key: 'foundation', label: 'Fondations', color: categoryConfig.foundation.color },
|
||||
{ key: 'protocol', label: 'Protocoles', color: categoryConfig.protocol.color },
|
||||
{ key: 'software', label: 'Logiciels', color: categoryConfig.software.color },
|
||||
{ key: 'milestone', label: 'Jalons', color: categoryConfig.milestone.color },
|
||||
{ key: 'ecosystem', label: 'Écosystème', color: categoryConfig.ecosystem.color }
|
||||
];
|
||||
|
||||
let activeFilter = $state<FilterKey>('all');
|
||||
|
||||
const filtered = $derived(
|
||||
activeFilter === 'all'
|
||||
? timelineEvents
|
||||
: timelineEvents.filter((event) => event.category === activeFilter)
|
||||
);
|
||||
|
||||
const yearGroups = $derived.by(() => {
|
||||
const byYear = new Map<number, TimelineEvent[]>();
|
||||
for (const event of filtered) {
|
||||
const group = byYear.get(event.year);
|
||||
if (group) group.push(event);
|
||||
else byYear.set(event.year, [event]);
|
||||
}
|
||||
return [...byYear.entries()].sort((a, b) => a[0] - b[0]);
|
||||
});
|
||||
|
||||
const finalStats = [
|
||||
{ value: stats.yearsOfHistory, label: "Années d'histoire" },
|
||||
{ value: stats.totalSoftwares, label: 'Logiciels référencés' },
|
||||
{ value: stats.totalProtocols, label: 'Protocoles' },
|
||||
{ value: networkData.links.length, label: 'Liens de fédération' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section id="timeline" aria-labelledby="timeline-title">
|
||||
<div class="inner center">
|
||||
<header class="section-header stack">
|
||||
<p class="kicker">🕐 Chronologie</p>
|
||||
<h2 id="timeline-title">L'évolution du <span class="gradient-text">Fédiverse</span></h2>
|
||||
<p class="subtitle">
|
||||
De StatusNet en 2008 à l'Atlas de 2026 : {stats.yearsOfHistory} ans d'histoire du réseau
|
||||
social décentralisé.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters cluster" role="group" aria-label="Filtrer la chronologie par catégorie">
|
||||
{#each categoryFilters as filter (filter.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
style:--cat-color={filter.color}
|
||||
aria-pressed={activeFilter === filter.key}
|
||||
onclick={() => (activeFilter = filter.key)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
{#each yearGroups as [year, events] (year)}
|
||||
<section class="year-group" aria-labelledby="timeline-year-{year}">
|
||||
<h3 class="year-badge" id="timeline-year-{year}">{year}</h3>
|
||||
<ol class="events" role="list">
|
||||
{#each events as event, i (`${year}-${event.title}`)}
|
||||
{@const cat = categoryConfig[event.category]}
|
||||
<li class="event" class:left={i % 2 === 0} class:right={i % 2 === 1}>
|
||||
<article class="event-card" style:--event-color={event.color}>
|
||||
<p class="event-meta">
|
||||
<span class="event-dot" aria-hidden="true"></span>
|
||||
<span class="event-cat" style:--cat-label-color={cat.color}>{cat.label}</span>
|
||||
</p>
|
||||
<h4 class="event-title">{event.title}</h4>
|
||||
<p class="event-desc">{event.description}</p>
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid stats" role="list">
|
||||
{#each finalStats as stat (stat.label)}
|
||||
<div class="stat-card" role="listitem">
|
||||
<p class="stat-value gradient-text">{stat.value}</p>
|
||||
<p class="stat-label">{stat.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
background: var(--abyss);
|
||||
padding-block: var(--space-6);
|
||||
scroll-margin-top: var(--header-h);
|
||||
}
|
||||
|
||||
.inner {
|
||||
--center-max: 64rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
--stack-gap: var(--space-3);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--status-maintenance);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--ink-soft);
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
/* --- Filtres --- */
|
||||
|
||||
.filters {
|
||||
justify-content: center;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 500;
|
||||
color: var(--ink-soft);
|
||||
transition:
|
||||
background-color var(--duration-1) var(--ease-out),
|
||||
border-color var(--duration-1) var(--ease-out),
|
||||
color var(--duration-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
border-color: var(--cat-color);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pill[aria-pressed='true'] {
|
||||
background: color-mix(in oklch, var(--cat-color) 22%, transparent);
|
||||
border-color: var(--cat-color);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Chronologie --- */
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Ligne verticale en dégradé : à gauche sur mobile, centrée sur desktop */
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--space-3);
|
||||
translate: -50% 0;
|
||||
inline-size: 2px;
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2), var(--accent-4));
|
||||
}
|
||||
|
||||
.year-group {
|
||||
position: relative;
|
||||
margin-block-end: var(--space-5);
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
inline-size: fit-content;
|
||||
margin-inline: auto;
|
||||
margin-block-end: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-pill);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.event {
|
||||
position: relative;
|
||||
padding-inline-start: var(--space-5);
|
||||
}
|
||||
|
||||
/* Pastille sur la ligne */
|
||||
.event::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: var(--space-3);
|
||||
inset-block-start: var(--space-4);
|
||||
translate: -50% 0;
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--event-color, var(--accent));
|
||||
border: 2px solid var(--abyss);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--event-color, var(--accent)) 45%, transparent);
|
||||
}
|
||||
|
||||
.event-card {
|
||||
inline-size: fit-content;
|
||||
max-inline-size: 100%;
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
flex-shrink: 0;
|
||||
inline-size: 0.5rem;
|
||||
block-size: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--event-color, var(--accent));
|
||||
box-shadow: 0 0 0.5rem color-mix(in oklch, var(--event-color, var(--accent)) 70%, transparent);
|
||||
}
|
||||
|
||||
.event-cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--cat-label-color);
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: var(--text-1);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* Alternance gauche/droite sur desktop */
|
||||
@media (min-width: 48rem) {
|
||||
.timeline::before {
|
||||
inset-inline-start: 50%;
|
||||
}
|
||||
|
||||
.event {
|
||||
inline-size: calc(50% - var(--space-5));
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.event.left {
|
||||
margin-inline-end: auto;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.event.left .event-card {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.event.left .event-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.event.left::before {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: calc(-1 * var(--space-5));
|
||||
translate: 50% 0;
|
||||
}
|
||||
|
||||
.event.right {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.event.right::before {
|
||||
inset-inline-start: calc(-1 * var(--space-5));
|
||||
}
|
||||
}
|
||||
|
||||
/* Container query : carte compacte quand la chronologie est étroite */
|
||||
@container (max-width: 30rem) {
|
||||
.event-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.event-desc {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Statistiques finales --- */
|
||||
|
||||
.stats {
|
||||
--grid-min: 11rem;
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: var(--space-3);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
export interface CompareRow {
|
||||
aspect: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Légende visible du tableau (sert aussi de description accessible). */
|
||||
caption: string;
|
||||
/** Libellé de la première colonne (en-têtes de ligne). */
|
||||
firstColumn?: string;
|
||||
/** En-têtes des colonnes de données. */
|
||||
columns: string[];
|
||||
rows: CompareRow[];
|
||||
/** Index dans `columns` de la colonne mise en valeur (-1 = aucune). */
|
||||
highlight?: number;
|
||||
/** Étiquette de la région de défilement horizontal. */
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
let { caption, firstColumn = 'Aspect', columns, rows, highlight = -1, ariaLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex — région défilante : tabindex="0" la rend
|
||||
accessible au clavier (défilement horizontal du tableau), c'est voulu. -->
|
||||
<div class="table-scroll" role="region" aria-label={ariaLabel} tabindex="0">
|
||||
<table>
|
||||
<caption>{caption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{firstColumn}</th>
|
||||
{#each columns as column, i (column)}
|
||||
<th scope="col" class:highlight={i === highlight}>{column}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.aspect)}
|
||||
<tr>
|
||||
<th scope="row">{row.aspect}</th>
|
||||
{#each row.values as value, i (i)}
|
||||
<td class:highlight={i === highlight}>{value}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
table {
|
||||
inline-size: 100%;
|
||||
min-inline-size: 34rem;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
caption {
|
||||
text-align: start;
|
||||
padding: var(--space-3) var(--space-3) 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
|
||||
tbody tr:last-child th,
|
||||
tbody tr:last-child td {
|
||||
border-block-end: 0;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
border-block-end-color: var(--border-strong);
|
||||
}
|
||||
|
||||
tbody th {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* Colonne mise en valeur : couleur accent ET fond teinté + texte en gras
|
||||
(l'information n'est jamais portée par la couleur seule côté contenu). */
|
||||
th.highlight,
|
||||
td.highlight {
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 9%, transparent);
|
||||
}
|
||||
|
||||
thead th.highlight {
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
}
|
||||
|
||||
td.highlight {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
tbody tr:hover th,
|
||||
tbody tr:hover td {
|
||||
background: color-mix(in oklch, var(--ink) 5%, transparent);
|
||||
}
|
||||
|
||||
tbody tr:hover td.highlight {
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
export interface FeatureItem {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: FeatureItem[];
|
||||
/** Taille minimale des colonnes de la grille (primitive .grid). */
|
||||
min?: string;
|
||||
}
|
||||
|
||||
let { items, min = '15rem' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="feature-cq">
|
||||
<ul class="grid" role="list" style:--grid-min={min}>
|
||||
{#each items as item (item.title)}
|
||||
<li class="feature-card">
|
||||
<span class="feature-icon"><Icon name={item.icon} /></span>
|
||||
<div class="feature-text">
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container query : la carte passe en disposition horizontale
|
||||
quand son conteneur (pas le viewport) est assez large. */
|
||||
.feature-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 2.75rem;
|
||||
block-size: 2.75rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-1);
|
||||
margin-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 30rem) {
|
||||
.feature-card {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" module>
|
||||
/* Icônes SVG inline (24×24, trait currentColor) — tracés statiques locaux,
|
||||
aucune dépendance. Contenu interne de confiance, injecté via {@html}. */
|
||||
const ICONS = {
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11.2 12.2 20 3.5"/><path d="m16 7 3 3"/>',
|
||||
server:
|
||||
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/>',
|
||||
'server-off':
|
||||
'<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01"/><path d="M7 16.5h.01"/><path d="m3 3 18 18"/>',
|
||||
lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
zap: '<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>',
|
||||
bitcoin:
|
||||
'<path d="M9 4h4a3 3 0 0 1 0 6H9V4z"/><path d="M9 10h5a3 3 0 0 1 0 6H9v-6z"/><path d="M11 2v2"/><path d="M14 2v2"/><path d="M11 20v-2"/><path d="M14 20v-2"/>',
|
||||
radio:
|
||||
'<circle cx="12" cy="12" r="1.5"/><path d="M8.5 8.5a5 5 0 0 0 0 7"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M5.6 5.6a9 9 0 0 0 0 12.8"/><path d="M18.4 5.6a9 9 0 0 1 0 12.8"/>',
|
||||
globe:
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a13.5 13.5 0 0 1 0 18"/><path d="M12 3a13.5 13.5 0 0 0 0 18"/>',
|
||||
message:
|
||||
'<path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 20l1.2-5A8.5 8.5 0 1 1 21 11.5z"/>',
|
||||
external:
|
||||
'<path d="M14 4h6v6"/><path d="M20 4 10 14"/><path d="M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6"/>',
|
||||
github:
|
||||
'<path d="M15 21v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.6 1.3a12.3 12.3 0 0 0-6.3 0C6.2 2.8 5.1 3.1 5.1 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/><path d="M9 18c-4.5 1.5-4.5-2.5-6-3"/>',
|
||||
shield: '<path d="M12 2 4 5.5v5.6c0 4.9 3.4 9.3 8 10.9 4.6-1.6 8-6 8-10.9V5.5L12 2z"/>',
|
||||
'user-x':
|
||||
'<circle cx="9" cy="8" r="4"/><path d="M2.5 21v-1a6.5 6.5 0 0 1 11-4.7"/><path d="m17 10 5 5"/><path d="m22 10-5 5"/>',
|
||||
'eye-off':
|
||||
'<path d="m3 3 18 18"/><path d="M10.5 5.2A9.8 9.8 0 0 1 12 5c7 0 10 7 10 7a17.4 17.4 0 0 1-2.9 3.6"/><path d="M6.6 6.6A16.9 16.9 0 0 0 2 12s3 7 10 7a10 10 0 0 0 4.3-1"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/>',
|
||||
fingerprint:
|
||||
'<path d="M6.8 6.8A7 7 0 0 1 19 13c0 2.8-.4 5.4-1.1 7.5"/><path d="M5.2 10A7 7 0 0 0 5 13c0 3-.9 5.2-1.9 6.7"/><path d="M12 11a2 2 0 0 0-2 2c0 2.6-.6 5-1.7 7"/><path d="M14 13a2 2 0 0 0-2-2"/><path d="M14 13c0 2.9-.3 5.5-1 8"/>',
|
||||
phone:
|
||||
'<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 2 .7 2.9a2 2 0 0 1-.4 2.1L8.1 10a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.9.6 2.9.7a2 2 0 0 1 1.7 2z"/>',
|
||||
users:
|
||||
'<circle cx="9" cy="8" r="4"/><path d="M2 21v-1a7 7 0 0 1 14 0v1"/><path d="M16.5 4.2a4 4 0 0 1 0 7.6"/><path d="M18 14.3A7 7 0 0 1 22 20v1"/>',
|
||||
chevron: '<path d="m6 9 6 6 6-6"/>'
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, size = 20 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html ICONS[name]}
|
||||
</svg>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
export interface StepItem {
|
||||
step: string;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: StepItem[];
|
||||
}
|
||||
|
||||
let { items }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="steps-cq">
|
||||
<ol class="grid" role="list" style:--grid-min="17rem">
|
||||
{#each items as item (item.step)}
|
||||
<li class="step-card">
|
||||
<span class="step-number" aria-hidden="true">{item.step}</span>
|
||||
<span class="step-icon"><Icon name={item.icon} size={24} /></span>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.description}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container query : rythme plus généreux quand le conteneur s'élargit. */
|
||||
.steps-cq {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-4);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: color-mix(in oklch, var(--page-accent, var(--accent)) 25%, transparent);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 3rem;
|
||||
block-size: 3rem;
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--page-accent-text, var(--accent-text));
|
||||
background: color-mix(in oklch, var(--page-accent, var(--accent)) 14%, transparent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@container (min-width: 45rem) {
|
||||
.step-card {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user