feat: refonte OKI — constellation, carte SVG déterministe, timeline interactive

- Background : shader Three.js remplacé par la constellation zetwal (DOM/SVG, zéro WebGL)
- Carte : graphe 3D aléatoire remplacé par un SVG 2D au layout baké au build
  (scripts/build-layout.mjs, positions déterministes), nœuds focusables, pan/zoom, filtres sans re-layout
- Timeline : 4 ères, reveal syncopé gwoka, liens événement→fiche catalogue (module software-modal),
  mois affichés, corrections factuelles (Twitter 2022, Mastodon 2016, PeerTube 2017, Bluesky 2024, 105 logiciels)
- Charte OKI : tokens + bridge, Archivo/Inter self-hébergées, flag-bar, KineticText, footer fédéré
- Dette : three retiré (JS initial ~828 Ko → 77 Ko), OG/Twitter/JSON-LD/robots/sitemap,
  licence CC BY-SA 4.0, fediverse.json mort supprimé, icônes PWA 121 Ko → 9 Ko
- Lighthouse mobile : 91/97/100/100 — tests vitest 6/6, svelte-check 0/0
This commit is contained in:
sucupira
2026-07-21 13:39:38 -04:00
parent 93c577c5d3
commit 9b574d918c
38 changed files with 2333 additions and 9297 deletions
+15 -66
View File
@@ -9,15 +9,14 @@
"version": "4.0.0", "version": "4.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"d3-force": "^3.0.0", "@fontsource/archivo": "^5.3.0"
"three": "^0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.1", "@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^7.2.0", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/d3-force": "^3.0.10", "@types/d3-force": "^3.0.10",
"@types/three": "^0.185.1", "d3-force": "^3.0.0",
"svelte": "^5.56.6", "svelte": "^5.56.6",
"svelte-check": "^4.7.3", "svelte-check": "^4.7.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -25,13 +24,6 @@
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -66,6 +58,15 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@fontsource/archivo": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/archivo/-/archivo-5.3.0.tgz",
"integrity": "sha512-5DIMgPVJRi62OqdOVoogCFxP73EkNM/E0YVTSDIQlDEJfDbxqZduwM/YoNZwC9Sx1CDgpKinqf8ckRY1QNIecw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -515,13 +516,6 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.0"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -572,28 +566,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -601,13 +573,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.10", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -821,6 +786,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -830,6 +796,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
@@ -844,6 +811,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -853,6 +821,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -955,13 +924,6 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1275,13 +1237,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1558,12 +1513,6 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+3 -5
View File
@@ -2,7 +2,6 @@
"name": "atlas-fediverse", "name": "atlas-fediverse",
"version": "4.0.0", "version": "4.0.0",
"description": "", "description": "",
"main": "index.js",
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"dev": "vite dev", "dev": "vite dev",
@@ -12,19 +11,18 @@
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "CC-BY-SA-4.0",
"type": "module", "type": "module",
"private": "true", "private": "true",
"dependencies": { "dependencies": {
"d3-force": "^3.0.0", "@fontsource/archivo": "^5.3.0"
"three": "^0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.1", "@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^7.2.0", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/d3-force": "^3.0.10", "@types/d3-force": "^3.0.10",
"@types/three": "^0.185.1", "d3-force": "^3.0.0",
"svelte": "^5.56.6", "svelte": "^5.56.6",
"svelte-check": "^4.7.3", "svelte-check": "^4.7.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
/* build-layout.mjs — pré-calcule les positions 2D de la carte de l'atlas.
*
* ⚠️ REJOUER CE SCRIPT À CHAQUE MODIFICATION de `src/lib/data/network.json` :
* node scripts/build-layout.mjs
* puis committer `src/lib/data/network-layout.json` avec les données.
*
* La simulation d3-force est entièrement déterministe :
* - positions initiales dérivées d'un hash stable de l'id (FNV-1a), pas de
* Math.random() — même entrée ⇒ même sortie, bit pour bit ;
* - ticks synchrones à nombre fixe (simulation.stop() + tick()), indépendants
* de l'horloge et de l'environnement.
*
* Sortie : src/lib/data/network-layout.json
* { viewBox: { width, height }, nodes: { [id]: { x, y } } }
* coordonnées normalisées dans le viewBox, arrondies à 0.1 près.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
forceSimulation,
forceLink,
forceManyBody,
forceCollide,
forceX,
forceY
} from 'd3-force';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const networkPath = join(root, 'src/lib/data/network.json');
const outPath = join(root, 'src/lib/data/network-layout.json');
const network = JSON.parse(readFileSync(networkPath, 'utf8'));
/** Hash FNV-1a 32 bits — graine stable par id, indépendante de la plateforme. */
function hashId(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/* ————— Positions initiales déterministes —————
Chaque catégorie occupe un secteur d'un grand cercle ; les nœuds sont
dispersés autour du centre de leur catégorie selon leur hash. La simulation
part ainsi d'un état proche de la topologie finale (convergence rapide,
clusters lisibles). */
const categories = [...new Set(network.nodes.map((n) => n.category))].sort();
const catIndex = new Map(categories.map((c, i) => [c, i]));
// Ellipse large (ratio proche du viewBox 1200×800) : les clusters de
// catégories s'y répartissent et la carte remplit le cadre.
const CLUSTER_RX = 340;
const CLUSTER_RY = 210;
const JITTER_RADIUS = 60;
/** Cible de placement d'une catégorie sur l'ellipse. */
function categoryTarget(category) {
const angle = (2 * Math.PI * catIndex.get(category)) / categories.length;
return { x: CLUSTER_RX * Math.cos(angle), y: CLUSTER_RY * Math.sin(angle) };
}
const nodes = network.nodes.map((n, i) => {
const h = hashId(n.id);
const target = categoryTarget(n.category);
const jAngle = ((h % 1000) / 1000) * 2 * Math.PI;
const jRadius = (((h >> 10) % 1000) / 1000) * JITTER_RADIUS;
return {
...n,
index: i,
x: target.x + jRadius * Math.cos(jAngle),
y: target.y + jRadius * Math.sin(jAngle)
};
});
const links = network.links.map((l) => ({
source: l.source,
target: l.target,
type: l.type
}));
/* ————— Paramètres de forces —————
- liens « strong » courts (30) pour souder les communautés, « weak »
longs (70) pour espacer les ponts inter-catégories ;
- répulsion modérée (-45) : le graphe est dense (873 liens / 105 nœuds),
une charge trop forte éclaterait les clusters ;
- collision = rayon visuel du nœud + marge, pour éviter les
superpositions une fois rendu en SVG ;
- forceX/forceY par catégorie vers la cible elliptique (0.08) : ancre
chaque cluster sans écraser la structure interne. */
const simulation = forceSimulation(nodes)
.force(
'link',
forceLink(links)
.id((d) => d.id)
.distance((d) => (d.type === 'strong' ? 30 : 70))
.strength((d) => (d.type === 'strong' ? 0.7 : 0.2))
)
.force('charge', forceManyBody().strength(-45))
.force('collide', forceCollide().radius((d) => 4 + (d.val ?? 5) * 0.6 + 6).iterations(2))
.force('x', forceX((d) => categoryTarget(d.category).x).strength(0.08))
.force('y', forceY((d) => categoryTarget(d.category).y).strength(0.08))
.alpha(1)
.alphaDecay(0.02)
.stop();
// Convergence synchrone à nombre de ticks fixe (≈ 3× le temps de stabilisation
// théorique log(0.001)/log(1-0.02) ≈ 342) : reproductible à l'identique.
const TICKS = 600;
for (let i = 0; i < TICKS; i++) simulation.tick();
/* ————— Normalisation dans le viewBox 1200×800 (marge 60) ————— */
const VIEW_W = 1200;
const VIEW_H = 800;
const MARGIN = 60;
const xs = nodes.map((n) => n.x);
const ys = nodes.map((n) => n.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const scale = Math.min(
(VIEW_W - 2 * MARGIN) / Math.max(maxX - minX, 1e-9),
(VIEW_H - 2 * MARGIN) / Math.max(maxY - minY, 1e-9)
);
// Centrage du nuage dans le viewBox.
const offX = MARGIN + (VIEW_W - 2 * MARGIN - (maxX - minX) * scale) / 2;
const offY = MARGIN + (VIEW_H - 2 * MARGIN - (maxY - minY) * scale) / 2;
const positions = {};
for (const n of nodes) {
const x = Math.round((offX + (n.x - minX) * scale) * 10) / 10;
const y = Math.round((offY + (n.y - minY) * scale) * 10) / 10;
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error(`Position non finie pour « ${n.id} » — ajuster les forces.`);
}
positions[n.id] = { x, y };
}
/* ————— Contrôles qualité ————— */
const ids = Object.keys(positions);
if (ids.length !== network.nodes.length) {
throw new Error(`${ids.length} positions pour ${network.nodes.length} nœuds.`);
}
let minDist = Infinity;
let closest = null;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = positions[ids[i]];
const b = positions[ids[j]];
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < minDist) {
minDist = d;
closest = [ids[i], ids[j]];
}
}
}
if (minDist < 8) {
console.warn(
`⚠️ distance minimale ${minDist.toFixed(1)}px entre « ${closest[0]} » et « ${closest[1]} » — envisager forceCollide plus large.`
);
}
const layout = {
viewBox: { width: VIEW_W, height: VIEW_H },
nodes: positions
};
writeFileSync(outPath, JSON.stringify(layout, null, 2) + '\n');
console.log(`${ids.length} positions écrites dans src/lib/data/network-layout.json`);
console.log(` distance minimale entre nœuds : ${minDist.toFixed(1)}px (${closest[0]}${closest[1]})`);
console.log(` alpha résiduel après ${TICKS} ticks : ${simulation.alpha().toFixed(5)}`);
+6 -1
View File
@@ -1,8 +1,13 @@
/* app.css — point d'entrée des styles, ordre de cascade explicite */ /* app.css — point d'entrée des styles, ordre de cascade explicite.
oki-tokens.css et oki-bridge.css sont hors layer : ils priment sur
tokens.css / themes/default.css (charte OKI = seule identité). */
@layer reset, tokens, base, layouts, components, utilities; @layer reset, tokens, base, layouts, components, utilities;
@import './styles/reset.css' layer(reset); @import './styles/reset.css' layer(reset);
@import './styles/tokens.css' layer(tokens); @import './styles/tokens.css' layer(tokens);
@import './styles/themes/default.css' layer(tokens); @import './styles/themes/default.css' layer(tokens);
@import './styles/oki-tokens.css';
@import './styles/oki-bridge.css';
@import './styles/base.css' layer(base); @import './styles/base.css' layer(base);
@import './styles/oki-base.css' layer(base);
@import './styles/layouts.css' layer(layouts); @import './styles/layouts.css' layer(layouts);
+53 -26
View File
@@ -8,14 +8,58 @@
name="description" name="description"
content="Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé." content="Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé."
/> />
<meta name="theme-color" content="#001219" /> <meta name="theme-color" content="#0D0D0D" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="dark" />
<link rel="canonical" href="https://syel.o-k-i.net/" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://syel.o-k-i.net/" />
<meta property="og:title" content="Le Grand Atlas du Fédiverse — 2026" />
<meta
property="og:description"
content="Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé."
/>
<meta property="og:image" content="https://syel.o-k-i.net/icons/icon-512.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Logo du Grand Atlas du Fédiverse" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:site_name" content="Le Grand Atlas du Fédiverse" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Le Grand Atlas du Fédiverse — 2026" />
<meta
name="twitter:description"
content="Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé."
/>
<meta name="twitter:image" content="https://syel.o-k-i.net/icons/icon-512.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Le Grand Atlas du Fédiverse",
"url": "https://syel.o-k-i.net/",
"description": "Cartographie interactive du Fédiverse : 105 logiciels, 12 protocoles, 18 ans d'histoire du réseau social décentralisé.",
"applicationCategory": "ReferenceApplication",
"operatingSystem": "Web",
"inLanguage": "fr",
"license": "https://creativecommons.org/licenses/by-sa/4.0/",
"creator": {
"@type": "Organization",
"name": "ORGANISATION KA INTERNATIONALE",
"url": "https://o-k-i.net"
}
}
</script>
<link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/icons/logo.svg" /> <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/icons/logo.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
<link <link
rel="preload" rel="preload"
href="%sveltekit.assets%/fonts/space-grotesk-var.woff2" href="%sveltekit.assets%/fonts/archivo-latin-700-normal.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
@@ -27,25 +71,8 @@
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
<style> <!-- Archivo 600-900 (titres) + Inter variable (corps), auto-hébergées, font-display: swap -->
/* 2 fichiers WOFF2 variables, auto-hébergés, font-display: swap. <link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
Dans app.html pour bénéficier de %sveltekit.assets% (base /fediverse). */
@font-face {
font-family: 'Space Grotesk';
src: url('%sveltekit.assets%/fonts/space-grotesk-var.woff2') format('woff2');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('%sveltekit.assets%/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
</style>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
@@ -53,7 +80,7 @@
<div <div
id="boot-warning" id="boot-warning"
hidden hidden
style="position: fixed; inset-inline: 1rem; inset-block-end: 1rem; z-index: 9999; padding: 1rem 1.25rem; border: 2px solid #f72585; border-radius: 1rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; font-size: 0.9375rem; line-height: 1.5; box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);" style="position: fixed; inset-inline: 1rem; inset-block-end: 1rem; z-index: 9999; padding: 1rem 1.25rem; border: 2px solid #FDB813; border-radius: 6px; background: #0D0D0D; color: #FFF8E7; font-family: system-ui, sans-serif; font-size: 0.9375rem; line-height: 1.5; box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4);"
> >
<strong>L'application n'a pas pu démarrer.</strong> <strong>L'application n'a pas pu démarrer.</strong>
Si vous lisez ce fichier en local (<code>file://</code>), servez le dossier Si vous lisez ce fichier en local (<code>file://</code>), servez le dossier
@@ -63,10 +90,10 @@
</div> </div>
<noscript> <noscript>
<div <div
style="padding: 1rem 1.25rem; background: #001219; color: #eef4f8; font-family: system-ui, sans-serif; text-align: center;" style="padding: 1rem 1.25rem; background: #0D0D0D; color: #FFF8E7; font-family: system-ui, sans-serif; text-align: center;"
> >
JavaScript est désactivé : le contenu de l'Atlas reste lisible, mais la carte 3D et les JavaScript est désactivé : le contenu de l'Atlas reste lisible, mais la carte
fenêtres de détails sont indisponibles. interactive et les fenêtres de détails sont indisponibles.
</div> </div>
</noscript> </noscript>
<script src="%sveltekit.assets%/boot-check.js" defer></script> <script src="%sveltekit.assets%/boot-check.js" defer></script>
@@ -6,7 +6,6 @@
const projectLinks = [ const projectLinks = [
{ href: `${base}/#map`, labelKey: 'footer.about' }, { href: `${base}/#map`, labelKey: 'footer.about' },
{ href: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', labelKey: 'footer.license' }, { 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' } { href: `${base}/data/fediverse.json`, labelKey: 'footer.api' }
] as const; ] as const;
@@ -35,6 +34,7 @@
</script> </script>
<footer> <footer>
<div class="flag-bar" aria-hidden="true"></div>
<div class="center stack footer-stack"> <div class="center stack footer-stack">
<div class="footer-grid"> <div class="footer-grid">
<div class="stack brand-col"> <div class="stack brand-col">
@@ -98,6 +98,10 @@
</div> </div>
<div class="bottom-bar stack"> <div class="bottom-bar stack">
<p class="small muted fede">
Un service libre opéré par
<a href="https://o-k-i.net" rel="external">ORGANISATION KA INTERNATIONALE · o-k-i.net</a>
</p>
<p class="small muted">{t('footer.rights')}</p> <p class="small muted">{t('footer.rights')}</p>
<p class="small muted">{t('footer.credits')}</p> <p class="small muted">{t('footer.credits')}</p>
</div> </div>
@@ -108,11 +112,13 @@
footer { footer {
background: var(--abyss); background: var(--abyss);
border-block-start: 1px solid var(--border); border-block-start: 1px solid var(--border);
padding-block: var(--space-6) var(--space-4); padding-block: 0 var(--space-4);
} }
/* La flag-bar (oki-base.css) est plaquée en haut du footer, sans padding */
.footer-stack { .footer-stack {
--stack-gap: var(--space-5); --stack-gap: var(--space-5);
padding-block-start: var(--space-6);
} }
.footer-grid { .footer-grid {
@@ -231,6 +237,17 @@
padding-block-start: var(--space-3); padding-block-start: var(--space-3);
} }
/* Ligne fédérée (charte §1.11) : texte muted, lien or */
.fede a {
color: var(--or-oki);
text-decoration: underline;
text-underline-offset: 0.15em;
}
.fede a:hover {
color: var(--or-clair);
}
@media (min-width: 48rem) { @media (min-width: 48rem) {
.footer-grid { .footer-grid {
grid-template-columns: 2fr 1fr 1fr 1fr; grid-template-columns: 2fr 1fr 1fr 1fr;
@@ -156,6 +156,8 @@
</ul> </ul>
</nav> </nav>
{/if} {/if}
<div class="flag-bar" aria-hidden="true"></div>
</header> </header>
<style> <style>
@@ -179,6 +181,14 @@
transform: translateY(-100%); transform: translateY(-100%);
} }
/* Signature n°1 : flag-bar en bord inférieur de la nav (charte §1.5).
La classe .flag-bar vit dans oki-base.css — ici, son ancrage. */
header .flag-bar {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
}
header.scrolled { header.scrolled {
background: var(--glass); background: var(--glass);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
@@ -1,11 +1,88 @@
<script lang="ts"> <script lang="ts">
/* HeroSection — plein viewport, shader océan en fond, titre + CTA. */ /* HeroSection — plein viewport, constellation zetwal en fond, titre + CTA.
Fond charte §7 (portails fédivers) : étoiles zetwal du sprite OKI
reliées par de fins filets --line, dérive lente au scroll via
animation-timeline: scroll(), quelques étoiles en éclat --or-oki
qui pulsent (transform/opacity, délais syncopés). Zéro JS, zéro WebGL. */
import { base } from '$app/paths';
import { stats } from '$lib/data'; import { stats } from '$lib/data';
import HeroShader from './HeroShader.svelte';
interface Star {
cx: number;
cy: number;
s: number;
eclat?: boolean;
/** Délai syncopé (3+3+2) de la pulsation — charte §1.7 */
delay?: number;
}
/* Positions dans le viewBox 1440×900 — étoiles + maillage de filets */
const stars: Star[] = [
{ cx: 150, cy: 190, s: 30 },
{ cx: 430, cy: 100, s: 22 },
{ cx: 770, cy: 160, s: 38, eclat: true, delay: 0 },
{ cx: 1130, cy: 120, s: 24 },
{ cx: 1330, cy: 270, s: 30 },
{ cx: 990, cy: 390, s: 20 },
{ cx: 630, cy: 340, s: 26, eclat: true, delay: 300 },
{ cx: 270, cy: 430, s: 22 },
{ cx: 100, cy: 650, s: 26 },
{ cx: 490, cy: 710, s: 32, eclat: true, delay: 660 },
{ cx: 870, cy: 650, s: 22 },
{ cx: 1240, cy: 730, s: 28 },
{ cx: 1390, cy: 530, s: 20 }
];
const links: [number, number][] = [
[0, 1],
[1, 2],
[2, 3],
[3, 4],
[0, 7],
[7, 6],
[6, 2],
[6, 5],
[5, 3],
[7, 8],
[8, 9],
[9, 10],
[10, 5],
[10, 11],
[11, 12],
[12, 4]
];
</script> </script>
<section id="hero" aria-labelledby="hero-title"> <section id="hero" aria-labelledby="hero-title">
<HeroShader /> <svg
class="constellation"
viewBox="0 0 1440 900"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<g class="drift">
{#each links as [a, b] (`${a}-${b}`)}
<line
class="fil"
x1={stars[a].cx}
y1={stars[a].cy}
x2={stars[b].cx}
y2={stars[b].cy}
/>
{/each}
{#each stars as star (`${star.cx}-${star.cy}`)}
<use
href="{base}/icons.svg#zetwal"
class:eclat={star.eclat}
style:animation-delay={star.delay !== undefined ? `${star.delay}ms` : undefined}
x={star.cx - star.s / 2}
y={star.cy - star.s / 2}
width={star.s}
height={star.s}
/>
{/each}
</g>
</svg>
<div class="veil" aria-hidden="true"></div> <div class="veil" aria-hidden="true"></div>
<div class="content center stack"> <div class="content center stack">
@@ -47,9 +124,6 @@
<style> <style>
section { 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; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -57,11 +131,75 @@
min-block-size: 100svh; min-block-size: 100svh;
overflow: hidden; overflow: hidden;
background: var(--abyss); background: var(--abyss);
color: var(--on-accent); color: var(--blanc-creme);
scroll-margin-top: var(--header-h); scroll-margin-top: var(--header-h);
} }
/* Voile de lisibilité : garantit le contraste du texte sur le shader */ /* ── Constellation zetwal (charte §7) ── */
.constellation {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
}
.fil {
stroke: var(--line);
stroke-width: 1;
}
.constellation use {
color: var(--blanc-creme);
opacity: 0.45;
}
/* Étoiles en éclat : or OKI, pulsation douce transform/opacity */
.constellation use.eclat {
color: var(--or-oki);
opacity: 0.8;
transform-box: fill-box;
transform-origin: center;
}
@media (prefers-reduced-motion: no-preference) {
.constellation use.eclat {
animation: zetwal-pulse calc(var(--dur-phrase) * 3) var(--ease-ka) infinite;
}
}
@keyframes zetwal-pulse {
0%,
100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
/* Dérive lente au scroll — natif uniquement, sinon fond statique */
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.drift {
animation: constellation-drift linear both;
animation-timeline: scroll();
animation-range: 0 100svh;
}
}
}
@keyframes constellation-drift {
from {
transform: translateY(0);
}
to {
transform: translateY(5rem);
}
}
/* Voile de lisibilité : garantit le contraste du texte sur la constellation */
.veil { .veil {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -103,7 +241,7 @@
} }
.lede strong { .lede strong {
color: var(--on-accent); color: var(--blanc-creme);
} }
.cta { .cta {
@@ -1,201 +0,0 @@
<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>
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@
/* ProtocolSection — grille des 12 protocoles sur fond « data stream ». */ /* ProtocolSection — grille des 12 protocoles sur fond « data stream ». */
import { protocols, type Protocol } from '$lib/data'; import { protocols, type Protocol } from '$lib/data';
import ProtocolStream from './ProtocolStream.svelte'; import ProtocolStream from './ProtocolStream.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
const TYPES: Record<Protocol['type'], { label: string; color: string }> = { const TYPES: Record<Protocol['type'], { label: string; color: string }> = {
fediverse: { label: 'Protocole du Fédiverse', color: 'var(--accent-4-text)' }, fediverse: { label: 'Protocole du Fédiverse', color: 'var(--accent-4-text)' },
@@ -26,7 +27,7 @@
<div class="content center stack"> <div class="content center stack">
<header class="section-head stack"> <header class="section-head stack">
<p class="kicker">Standards &amp; Protocoles</p> <p class="kicker">Standards &amp; Protocoles</p>
<h2 id="protocols-title">Les fondations du <span class="gradient">Fédiverse</span></h2> <KineticText id="protocols-title" text="Les fondations du Fédiverse" />
<p class="subtitle"> <p class="subtitle">
Comprendre les protocoles qui permettent la communication décentralisée entre les Comprendre les protocoles qui permettent la communication décentralisée entre les
différentes plateformes. différentes plateformes.
@@ -146,7 +147,7 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--ocean); background: var(--ocean);
color: var(--on-accent); color: var(--blanc-creme);
padding-block: var(--space-6); padding-block: var(--space-6);
scroll-margin-top: var(--header-h); scroll-margin-top: var(--header-h);
} }
@@ -183,13 +184,6 @@
color: var(--accent-2-text); color: var(--accent-2-text);
} }
.gradient {
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle { .subtitle {
max-inline-size: var(--measure); max-inline-size: var(--measure);
color: var(--ink-soft); color: var(--ink-soft);
@@ -301,7 +295,7 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-small); font-size: var(--text-small);
color: var(--muted); color: var(--muted);
background: color-mix(in oklab, var(--on-accent) 6%, transparent); background: color-mix(in oklab, var(--blanc-creme) 6%, transparent);
border-radius: var(--radius-1); border-radius: var(--radius-1);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
} }
@@ -3,6 +3,8 @@
import { categoryById, ETATS, softwares, type Category, type Software } from '$lib/data'; import { categoryById, ETATS, softwares, type Category, type Software } from '$lib/data';
import { t } from '$lib/i18n/i18n.svelte'; import { t } from '$lib/i18n/i18n.svelte';
import { isTypingTarget } from '$lib/keyboard'; import { isTypingTarget } from '$lib/keyboard';
import KineticText from '$lib/components/motion/KineticText.svelte';
import { consumeSoftwareDetailsRequest } from '$lib/software-modal.svelte';
type StatusFilter = Software['etat'] | 'all'; type StatusFilter = Software['etat'] | 'all';
@@ -23,15 +25,15 @@
'En développement': 'var(--compat-none)' 'En développement': 'var(--compat-none)'
}; };
/** Choisit un texte contrasté (clair ou foncé) selon la luminance WCAG d'une couleur hex. */ /** Choisit un texte contrasté (crème ou foncé) selon la luminance WCAG d'une couleur hex. */
function contrastingInk(hex: string | undefined): string { function contrastingInk(hex: string | undefined): string {
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return 'var(--on-accent)'; if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return 'var(--blanc-creme)';
const channels = [1, 3, 5].map((start) => { const channels = [1, 3, 5].map((start) => {
const c = parseInt(hex.slice(start, start + 2), 16) / 255; 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); 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]; 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)'; return luminance > 0.18 ? 'var(--noir-oki)' : 'var(--blanc-creme)';
} }
function categoryOf(software: Software): Category | undefined { function categoryOf(software: Software): Category | undefined {
@@ -157,13 +159,20 @@
$effect(() => { $effect(() => {
if (selectedSoftware) closeButton?.focus(); if (selectedSoftware) closeButton?.focus();
}); });
// Ouverture de la fiche depuis une autre section (chronologie, etc.)
// via l'état partagé $lib/software-modal.svelte
$effect(() => {
const requested = consumeSoftwareDetailsRequest();
if (requested) openDetails(requested, null);
});
</script> </script>
<section id="catalog" aria-labelledby="catalog-title"> <section id="catalog" aria-labelledby="catalog-title">
<div class="inner center"> <div class="inner center">
<header class="section-header stack"> <header class="section-header stack">
<p class="kicker">Répertoire</p> <p class="kicker">Répertoire</p>
<h2 id="catalog-title">Les logiciels du <span class="gradient-text">Fédiverse</span></h2> <KineticText id="catalog-title" text="Les logiciels du Fédiverse" />
<p class="subtitle"> <p class="subtitle">
{softwares.length} logiciels référencés, classés par catégorie et filtrables par statut {softwares.length} logiciels référencés, classés par catégorie et filtrables par statut
d'activité. d'activité.
@@ -426,13 +435,6 @@
color: var(--accent-text); color: var(--accent-text);
} }
.gradient-text {
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle { .subtitle {
color: var(--ink-soft); color: var(--ink-soft);
max-inline-size: var(--measure); max-inline-size: var(--measure);
@@ -1,28 +1,75 @@
<script lang="ts"> <script lang="ts">
import { networkData, stats } from '$lib/data'; import { networkData, softwareById, stats, type Software } from '$lib/data';
import { timelineEvents, type TimelineEvent } from '$lib/data/timeline'; import { timelineEvents, type TimelineEvent } from '$lib/data/timeline';
import { reveal } from '$lib/motion/reveal';
import KineticText from '$lib/components/motion/KineticText.svelte';
import { requestSoftwareDetails } from '$lib/software-modal.svelte';
type CategoryKey = TimelineEvent['category']; type CategoryKey = TimelineEvent['category'];
type FilterKey = CategoryKey | 'all'; type FilterKey = CategoryKey | 'all';
/* Couleurs de catégories : tokens sémantiques (adaptés au thème clair/sombre). */ /* Couleurs de catégories : décor uniquement (l'or reste la seule couleur d'action). */
const categoryConfig = { const categoryConfig = {
foundation: { label: 'Fondation', color: 'var(--muted)' }, foundation: { label: 'Fondation', color: 'var(--muted)' },
protocol: { label: 'Protocole', color: 'var(--accent)' }, protocol: { label: 'Protocole', color: 'var(--turquoise-caraibes)' },
software: { label: 'Logiciel', color: 'var(--accent-2)' }, software: { label: 'Logiciel', color: 'var(--bleu-ocean)' },
milestone: { label: 'Jalon', color: 'var(--status-maintenance)' }, milestone: { label: 'Jalon', color: 'var(--rouge-oki)' },
ecosystem: { label: 'Écosystème', color: 'var(--accent-4)' } ecosystem: { label: 'Écosystème', color: 'var(--vert-oki)' }
} as const satisfies Record<CategoryKey, { label: string; color: string }>; } as const satisfies Record<CategoryKey, { label: string; color: string }>;
const categoryFilters: readonly { key: FilterKey; label: string; color: string }[] = [ const categoryFilters: readonly { key: FilterKey; label: string }[] = [
{ key: 'all', label: 'Tout', color: 'var(--accent)' }, { key: 'all', label: 'Tout' },
{ key: 'foundation', label: 'Fondations', color: categoryConfig.foundation.color }, { key: 'foundation', label: 'Fondations' },
{ key: 'protocol', label: 'Protocoles', color: categoryConfig.protocol.color }, { key: 'protocol', label: 'Protocoles' },
{ key: 'software', label: 'Logiciels', color: categoryConfig.software.color }, { key: 'software', label: 'Logiciels' },
{ key: 'milestone', label: 'Jalons', color: categoryConfig.milestone.color }, { key: 'milestone', label: 'Jalons' },
{ key: 'ecosystem', label: 'Écosystème', color: categoryConfig.ecosystem.color } { key: 'ecosystem', label: 'Écosystème' }
]; ];
/* Marqueurs d'ère : les bornes couvrent la totalité des événements (2008 → 2026).
« Pionniers » étendu à 2015 et « essor » à 2019 pour ne laisser aucune année orpheline. */
const eras = [
{ id: 'pionniers', label: 'Les pionniers', from: 2008, to: 2015 },
{ id: 'essor', label: "L'essor d'ActivityPub", from: 2016, to: 2019 },
{ id: 'bascules', label: 'Les grandes bascules', from: 2020, to: 2022 },
{ id: 'consolidation', label: 'La consolidation', from: 2023, to: 2026 }
] as const;
interface EraGroup {
id: string;
label: string;
from: number;
to: number;
revealIndex: number;
years: YearGroup[];
}
const MONTHS_FR = [
'Janv.',
'Févr.',
'Mars',
'Avr.',
'Mai',
'Juin',
'Juil.',
'Août',
'Sept.',
'Oct.',
'Nov.',
'Déc.'
] as const;
interface TimelineItem {
event: TimelineEvent;
software: Software | undefined;
revealIndex: number;
}
interface YearGroup {
year: number;
items: TimelineItem[];
}
let activeFilter = $state<FilterKey>('all'); let activeFilter = $state<FilterKey>('all');
const filtered = $derived( const filtered = $derived(
@@ -31,14 +78,34 @@
: timelineEvents.filter((event) => event.category === activeFilter) : timelineEvents.filter((event) => event.category === activeFilter)
); );
const yearGroups = $derived.by(() => { /* Regroupe les événements filtrés par ère puis par année, et attribue
l'indice de cascade gwoka (reveal syncopé) sur l'ensemble du flux. */
const eraGroups = $derived.by((): EraGroup[] => {
let revealIndex = 0;
const groups: EraGroup[] = [];
for (const era of eras) {
const inEra = filtered.filter((event) => event.year >= era.from && event.year <= era.to);
if (inEra.length === 0) continue;
const byYear = new Map<number, TimelineEvent[]>(); const byYear = new Map<number, TimelineEvent[]>();
for (const event of filtered) { for (const event of inEra) {
const group = byYear.get(event.year); const group = byYear.get(event.year);
if (group) group.push(event); if (group) group.push(event);
else byYear.set(event.year, [event]); else byYear.set(event.year, [event]);
} }
return [...byYear.entries()].sort((a, b) => a[0] - b[0]); const eraRevealIndex = revealIndex++;
const years: YearGroup[] = [...byYear.entries()]
.sort((a, b) => a[0] - b[0])
.map(([year, events]) => ({
year,
items: events.map((event) => ({
event,
software: event.softwareId ? softwareById.get(event.softwareId) : undefined,
revealIndex: revealIndex++
}))
}));
groups.push({ ...era, revealIndex: eraRevealIndex, years });
}
return groups;
}); });
const finalStats = [ const finalStats = [
@@ -47,13 +114,17 @@
{ value: stats.totalProtocols, label: 'Protocoles' }, { value: stats.totalProtocols, label: 'Protocoles' },
{ value: networkData.links.length, label: 'Liens de fédération' } { value: networkData.links.length, label: 'Liens de fédération' }
]; ];
function formatMonth(month: number): string {
return MONTHS_FR[month - 1] ?? '';
}
</script> </script>
<section id="timeline" aria-labelledby="timeline-title"> <section id="timeline" aria-labelledby="timeline-title">
<div class="inner center"> <div class="inner center">
<header class="section-header stack"> <header class="section-header stack">
<p class="kicker">🕐 Chronologie</p> <p class="kicker">Chronologie</p>
<h2 id="timeline-title">L'évolution du <span class="gradient-text">Fédiverse</span></h2> <KineticText id="timeline-title" text="L'évolution du Fédiverse" />
<p class="subtitle"> <p class="subtitle">
De StatusNet en 2008 à l'Atlas de 2026 : {stats.yearsOfHistory} ans d'histoire du réseau De StatusNet en 2008 à l'Atlas de 2026 : {stats.yearsOfHistory} ans d'histoire du réseau
social décentralisé. social décentralisé.
@@ -65,7 +136,6 @@
<button <button
type="button" type="button"
class="pill" class="pill"
style:--cat-color={filter.color}
aria-pressed={activeFilter === filter.key} aria-pressed={activeFilter === filter.key}
onclick={() => (activeFilter = filter.key)} onclick={() => (activeFilter = filter.key)}
> >
@@ -75,31 +145,70 @@
</div> </div>
<div class="timeline"> <div class="timeline">
{#each yearGroups as [year, events] (year)} <!-- Ligne verticale : se dessine au scroll (scaleY + animation-timeline, sous garde) -->
<section class="year-group" aria-labelledby="timeline-year-{year}"> <div class="timeline-line" aria-hidden="true"></div>
<h3 class="year-badge" id="timeline-year-{year}">{year}</h3>
{#each eraGroups as era (era.id)}
<section class="era" aria-labelledby="timeline-era-{era.id}">
<header class="era-header" use:reveal={era.revealIndex}>
<h3 class="era-title" id="timeline-era-{era.id}">{era.label}</h3>
<p class="era-range">{era.from}&nbsp;&nbsp;{era.to}</p>
</header>
{#each era.years as yearGroup (yearGroup.year)}
<section class="year-group" aria-labelledby="timeline-year-{yearGroup.year}">
<h4 class="year-badge" id="timeline-year-{yearGroup.year}">{yearGroup.year}</h4>
<ol class="events" role="list"> <ol class="events" role="list">
{#each events as event, i (`${year}-${event.title}`)} {#each yearGroup.items as item, i (`${yearGroup.year}-${item.event.title}`)}
{@const cat = categoryConfig[event.category]} {@const cat = categoryConfig[item.event.category]}
<li class="event" class:left={i % 2 === 0} class:right={i % 2 === 1}> <li
<article class="event-card" style:--event-color={event.color}> class="event"
class:left={i % 2 === 0}
class:right={i % 2 === 1}
style:--event-color={item.event.color}
use:reveal={item.revealIndex}
>
<article class="event-card">
<p class="event-meta"> <p class="event-meta">
<span class="event-dot" aria-hidden="true"></span> <span class="event-dot" aria-hidden="true"></span>
<span class="event-cat" style:--cat-label-color={cat.color}>{cat.label}</span> <span class="event-cat" style:--cat-label-color={cat.color}>{cat.label}</span
>
{#if item.event.month}
<time
class="event-date"
datetime="{item.event.year}-{String(item.event.month).padStart(2, '0')}"
>
{formatMonth(item.event.month)} {item.event.year}
</time>
{/if}
</p> </p>
<h4 class="event-title">{event.title}</h4> <h5 class="event-title">{item.event.title}</h5>
<p class="event-desc">{event.description}</p> <p class="event-desc">{item.event.description}</p>
{#if item.software}
{@const software = item.software}
<button
type="button"
class="event-cta"
aria-label="Voir la fiche {software.name}"
onclick={() => requestSoftwareDetails(software.id)}
>
Voir la fiche {software.name}
<span aria-hidden="true">&nbsp;</span>
</button>
{/if}
</article> </article>
</li> </li>
{/each} {/each}
</ol> </ol>
</section> </section>
{/each} {/each}
</section>
{/each}
</div> </div>
<div class="grid stats" role="list"> <div class="grid stats" role="list">
{#each finalStats as stat (stat.label)} {#each finalStats as stat, i (stat.label)}
<div class="stat-card" role="listitem"> <div class="stat-card" role="listitem" use:reveal={i}>
<p class="stat-value gradient-text">{stat.value}</p> <p class="stat-value gradient-text">{stat.value}</p>
<p class="stat-label">{stat.label}</p> <p class="stat-label">{stat.label}</p>
</div> </div>
@@ -110,8 +219,8 @@
<style> <style>
section { section {
background: var(--abyss); background: var(--noir-profond);
padding-block: var(--space-6); padding-block: var(--space-5);
scroll-margin-top: var(--header-h); scroll-margin-top: var(--header-h);
} }
@@ -120,10 +229,10 @@
} }
.section-header { .section-header {
--stack-gap: var(--space-3); --stack-gap: var(--space-2);
align-items: center; align-items: center;
text-align: center; text-align: center;
margin-block-end: var(--space-5); margin-block-end: var(--space-4);
} }
.kicker { .kicker {
@@ -131,7 +240,7 @@
font-size: var(--text-small); font-size: var(--text-small);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.15em; letter-spacing: 0.15em;
color: var(--status-maintenance); color: var(--or-oki);
} }
.gradient-text { .gradient-text {
@@ -142,7 +251,7 @@
} }
.subtitle { .subtitle {
color: var(--ink-soft); color: var(--muted);
max-inline-size: var(--measure); max-inline-size: var(--measure);
} }
@@ -150,35 +259,42 @@
.filters { .filters {
justify-content: center; justify-content: center;
margin-block-end: var(--space-5); margin-block-end: var(--space-4);
} }
.pill { .pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-block-size: var(--tap-target); min-block-size: 2.75rem;
padding-inline: var(--space-3); padding-inline: var(--space-2);
border: 1px solid var(--border); background: var(--card-bg);
border-radius: var(--radius-pill); border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: var(--text-small); font-size: var(--text-small);
font-weight: 500; font-weight: 500;
color: var(--ink-soft); color: var(--muted);
transition: transition:
background-color var(--duration-1) var(--ease-out), background-color var(--dur-tanbou) var(--ease-ka),
border-color var(--duration-1) var(--ease-out), border-color var(--dur-tanbou) var(--ease-ka),
color var(--duration-1) var(--ease-out); color var(--dur-tanbou) var(--ease-ka);
} }
.pill:hover { .pill:hover {
border-color: var(--cat-color); border-color: var(--or-oki);
color: var(--ink); color: var(--blanc-creme);
} }
.pill:focus-visible {
outline: 2px solid var(--or-oki);
outline-offset: 2px;
}
/* Or = seule couleur d'action : état actif des filtres */
.pill[aria-pressed='true'] { .pill[aria-pressed='true'] {
background: color-mix(in oklch, var(--cat-color) 22%, transparent); background: var(--or-oki);
border-color: var(--cat-color); border-color: var(--or-oki);
color: var(--ink); color: var(--noir-oki);
font-weight: 600; font-weight: 700;
} }
/* --- Chronologie --- */ /* --- Chronologie --- */
@@ -188,20 +304,77 @@
container-type: inline-size; container-type: inline-size;
} }
/* Ligne verticale en dégradé : à gauche sur mobile, centrée sur desktop */ /* Ligne verticale : à gauche sur mobile, centrée sur desktop.
.timeline::before { Statique par défaut (fallback) ; animée au scroll sous garde. */
content: ''; .timeline-line {
position: absolute; position: absolute;
inset-block: 0; inset-block: 0;
inset-inline-start: var(--space-3); inset-inline-start: var(--space-2);
translate: -50% 0; translate: -50% 0;
inline-size: 2px; inline-size: 2px;
background: linear-gradient(180deg, var(--accent), var(--accent-2), var(--accent-4)); background: linear-gradient(180deg, var(--or-oki), var(--or-clair));
transform-origin: top;
} }
@keyframes timeline-line-draw {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.timeline-line {
animation: timeline-line-draw linear both;
animation-timeline: view();
}
}
}
/* --- Marqueurs d'ère --- */
.era {
position: relative;
margin-block-end: var(--space-4);
}
.era-header {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
gap: var(--space-1) var(--space-2);
margin-block-end: var(--space-3);
padding-block: var(--space-2);
background: var(--noir-profond);
}
.era-title {
font-family: var(--font-display);
font-size: var(--text-2);
font-weight: 800;
color: var(--blanc-creme);
}
.era-range {
padding: 0.125rem var(--space-1);
border: 1px solid var(--or-oki);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-small);
color: var(--or-oki);
}
/* --- Groupes annuels --- */
.year-group { .year-group {
position: relative; position: relative;
margin-block-end: var(--space-5); margin-block-end: var(--space-3);
} }
.year-badge { .year-badge {
@@ -209,59 +382,57 @@
z-index: 1; z-index: 1;
inline-size: fit-content; inline-size: fit-content;
margin-inline: auto; margin-inline: auto;
margin-block-end: var(--space-4); margin-block-end: var(--space-3);
padding: var(--space-2) var(--space-3); padding: 0.125rem var(--space-2);
background: var(--glass); background: var(--noir-oki);
border: 1px solid var(--glass-border); border: 1px solid var(--line);
border-radius: var(--radius-pill); border-radius: var(--radius-sm);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-1); font-size: var(--text-1);
color: var(--ink); color: var(--blanc-creme);
} }
.events { .events {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-3);
} }
.event { .event {
position: relative; position: relative;
padding-inline-start: var(--space-5); padding-inline-start: var(--space-4);
} }
/* Pastille sur la ligne */ /* Pastille sur la ligne : couleur propre à l'événement (donnée) */
.event::before { .event::before {
content: ''; content: '';
position: absolute; position: absolute;
inset-inline-start: var(--space-3); inset-inline-start: var(--space-2);
inset-block-start: var(--space-4); inset-block-start: var(--space-3);
translate: -50% 0; translate: -50% 0;
inline-size: 0.75rem; inline-size: 0.75rem;
block-size: 0.75rem; block-size: 0.75rem;
border-radius: 50%; border-radius: 50%;
background: var(--event-color, var(--accent)); background: var(--event-color, var(--or-oki));
border: 2px solid var(--abyss); border: 2px solid var(--noir-profond);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--event-color, var(--accent)) 45%, transparent); box-shadow: 0 0 0 2px color-mix(in oklch, var(--event-color, var(--or-oki)) 45%, transparent);
} }
.event-card { .event-card {
inline-size: fit-content; inline-size: fit-content;
max-inline-size: 100%; max-inline-size: 100%;
padding: var(--space-4); padding: var(--space-3);
background: var(--glass); background: var(--card-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--line);
border-radius: var(--radius-2); border-radius: var(--radius-md);
box-shadow: var(--shadow-1);
} }
.event-meta { .event-meta {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-1);
margin-block-end: var(--space-2); margin-block-end: var(--space-1);
} }
.event-dot { .event-dot {
@@ -269,8 +440,8 @@
inline-size: 0.5rem; inline-size: 0.5rem;
block-size: 0.5rem; block-size: 0.5rem;
border-radius: 50%; border-radius: 50%;
background: var(--event-color, var(--accent)); background: var(--event-color, var(--or-oki));
box-shadow: 0 0 0.5rem color-mix(in oklch, var(--event-color, var(--accent)) 70%, transparent); box-shadow: 0 0 0.5rem color-mix(in oklch, var(--event-color, var(--or-oki)) 70%, transparent);
} }
.event-cat { .event-cat {
@@ -281,24 +452,64 @@
color: var(--cat-label-color); color: var(--cat-label-color);
} }
.event-date {
margin-inline-start: auto;
font-family: var(--font-mono);
font-size: var(--text-small);
color: var(--muted);
}
.event-title { .event-title {
font-family: var(--font-display);
font-size: var(--text-1); font-size: var(--text-1);
font-weight: 700;
color: var(--blanc-creme);
margin-block-end: var(--space-1); margin-block-end: var(--space-1);
} }
.event-desc { .event-desc {
font-size: var(--text-small); font-size: var(--text-small);
color: var(--ink-soft); line-height: 1.6;
color: var(--muted);
}
/* Lien événement → fiche logiciel du catalogue : or = action */
.event-cta {
display: inline-flex;
align-items: center;
margin-block-start: var(--space-2);
padding: 0.375rem var(--space-1);
border: 1px solid var(--or-oki);
border-radius: var(--radius-sm);
font-family: var(--font-display);
font-size: var(--text-small);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--or-oki);
transition:
background-color var(--dur-tanbou) var(--ease-ka),
color var(--dur-tanbou) var(--ease-ka);
}
.event-cta:hover {
background: var(--or-oki);
color: var(--noir-oki);
}
.event-cta:focus-visible {
outline: 2px solid var(--or-oki);
outline-offset: 2px;
} }
/* Alternance gauche/droite sur desktop */ /* Alternance gauche/droite sur desktop */
@media (min-width: 48rem) { @media (min-width: 48rem) {
.timeline::before { .timeline-line {
inset-inline-start: 50%; inset-inline-start: 50%;
} }
.event { .event {
inline-size: calc(50% - var(--space-5)); inline-size: calc(50% - var(--space-4));
padding-inline-start: 0; padding-inline-start: 0;
} }
@@ -315,9 +526,13 @@
justify-content: flex-end; justify-content: flex-end;
} }
.event.left .event-date {
margin-inline-start: 0;
}
.event.left::before { .event.left::before {
inset-inline-start: auto; inset-inline-start: auto;
inset-inline-end: calc(-1 * var(--space-5)); inset-inline-end: calc(-1 * var(--space-4));
translate: 50% 0; translate: 50% 0;
} }
@@ -326,18 +541,14 @@
} }
.event.right::before { .event.right::before {
inset-inline-start: calc(-1 * var(--space-5)); inset-inline-start: calc(-1 * var(--space-4));
} }
} }
/* Container query : carte compacte quand la chronologie est étroite */ /* Container query : carte compacte quand la chronologie est étroite */
@container (max-width: 30rem) { @container (max-width: 30rem) {
.event-card { .event-card {
padding: var(--space-3); padding: var(--space-2);
}
.event-desc {
font-size: var(--text-small);
} }
} }
@@ -345,14 +556,14 @@
.stats { .stats {
--grid-min: 11rem; --grid-min: 11rem;
margin-block-start: var(--space-6); margin-block-start: var(--space-5);
} }
.stat-card { .stat-card {
padding: var(--space-3); padding: var(--space-2);
background: var(--glass); background: var(--card-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--line);
border-radius: var(--radius-2); border-radius: var(--radius-md);
text-align: center; text-align: center;
} }
@@ -364,6 +575,6 @@
.stat-label { .stat-label {
font-size: var(--text-small); font-size: var(--text-small);
color: var(--ink-soft); color: var(--muted);
} }
</style> </style>
@@ -0,0 +1,87 @@
<script lang="ts">
/**
* KineticText — titrage au scroll, split par mots (charte §2.6).
* Chemin unique : scrub CSS via animation-timeline: view() ; sans
* support ou en reduced-motion, le titre reste statique et visible
* (pas de fallback GSAP : zéro dépendance JS dans ce projet).
*/
import { syncopatedDelay } from '$lib/motion/tokens';
interface Props {
/** Chaîne intacte — restituée via aria-label. */
text: string;
/** Balise du titre (h1-h4). */
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span';
class?: string;
id?: string;
}
let { text, as = 'h2', class: className = '', id }: Props = $props();
/**
* Split par mots (jamais par caractères — langues à apostrophes).
* Sur un titre (h1-h4) : aria-label avec la chaîne intacte + mots
* aria-hidden. Sur span/p : aria-label est prohibé — les mots
* restent lisibles naturellement par les lecteurs d'écran.
*/
const words = $derived(text.split(/\s+/).filter(Boolean));
const named = $derived(['h1', 'h2', 'h3', 'h4'].includes(as));
function rangeFor(index: number): { start: number; end: number; delay: number } {
const delay = syncopatedDelay(index);
const start = Math.min(5 + (delay / 960) * 25, 55);
return { start, end: start + 30, delay };
}
</script>
<svelte:element
this={as}
{id}
class="kinetic-text {className}"
aria-label={named ? text : undefined}
>
{#each words as word, i (i)}
{@const range = rangeFor(i)}
<span
class="kt-word"
aria-hidden={named ? true : undefined}
style="--kt-start: {range.start}%; --kt-end: {range.end}%; --kt-delay: {range.delay}ms"
>{word}{#if i < words.length - 1}&nbsp;{/if}</span
>
{/each}
</svelte:element>
<style>
.kinetic-text {
margin: 0;
}
.kt-word {
display: inline-block;
will-change: transform, opacity;
}
/* Chemin natif : scrub via animation-timeline: view() (playbook §5.2).
L'état caché initial n'existe QUE dans ce bloc @supports :
sans JS ni support, le contenu reste intégralement visible. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.kt-word {
animation: kt-rise linear both;
animation-timeline: view();
animation-range: entry var(--kt-start) entry var(--kt-end);
}
}
}
@keyframes kt-rise {
from {
opacity: 0;
transform: translateY(60%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,428 @@
{
"viewBox": {
"width": 1200,
"height": 800
},
"nodes": {
"Mastodon": {
"x": 705.1,
"y": 112.8
},
"GoToSocial": {
"x": 677.1,
"y": 107.1
},
"Akkoma": {
"x": 713.6,
"y": 143.4
},
"Pleroma": {
"x": 745.5,
"y": 180.9
},
"Misskey": {
"x": 670,
"y": 180.9
},
"Sharkey": {
"x": 702.3,
"y": 218.2
},
"Friendica": {
"x": 724,
"y": 91.1
},
"Hubzilla": {
"x": 739.9,
"y": 66.3
},
"Streams": {
"x": 752.6,
"y": 91.9
},
"Iceshrimp": {
"x": 636.6,
"y": 147.1
},
"GNU social": {
"x": 713.1,
"y": 65.1
},
"Bonfire": {
"x": 755.2,
"y": 136.2
},
"microblog.pub": {
"x": 685.8,
"y": 241.5
},
"PixelFed": {
"x": 783.6,
"y": 138.9
},
"Vernissage": {
"x": 682.3,
"y": 269.8
},
"PeerTube": {
"x": 767,
"y": 162.2
},
"Owncast": {
"x": 839.6,
"y": 192.8
},
"Tube (Framasoft)": {
"x": 798.8,
"y": 232.9
},
"Castopod": {
"x": 777.8,
"y": 218.2
},
"Funkwhale": {
"x": 718.5,
"y": 171.5
},
"Loops": {
"x": 686.1,
"y": 134.2
},
"Ghost": {
"x": 841.4,
"y": 355.3
},
"WriteFreely": {
"x": 733.3,
"y": 290
},
"Plume": {
"x": 778.2,
"y": 245
},
"WordPress + ActivityPub": {
"x": 849.1,
"y": 395.8
},
"Drupal + ActivityPub": {
"x": 874.1,
"y": 359.8
},
"Lemmy": {
"x": 723.9,
"y": 199.5
},
"Mbin": {
"x": 691.5,
"y": 162.2
},
"NodeBB": {
"x": 594.1,
"y": 284.7
},
"Mobilizon": {
"x": 655.4,
"y": 260.5
},
"Gancio": {
"x": 714.2,
"y": 244.2
},
"Forgejo": {
"x": 666.9,
"y": 342.1
},
"Gitea": {
"x": 638.9,
"y": 329.6
},
"Nextcloud": {
"x": 744.9,
"y": 728.8
},
"ownCloud": {
"x": 686.6,
"y": 654.8
},
"BookWyrm": {
"x": 752.5,
"y": 237.4
},
"Wikidata": {
"x": 1031.9,
"y": 348.1
},
"Diaspora*": {
"x": 688.5,
"y": 60
},
"Element": {
"x": 713.7,
"y": 646
},
"Fedify": {
"x": 642,
"y": 175.2
},
"Mitra": {
"x": 696,
"y": 85.7
},
"Socialhome": {
"x": 784.5,
"y": 89.4
},
"kbin": {
"x": 638,
"y": 217.1
},
"Epicyon": {
"x": 696.9,
"y": 190.2
},
"Takahē": {
"x": 733.1,
"y": 118.2
},
"Smithereen": {
"x": 800.8,
"y": 187.2
},
"Kroeg": {
"x": 792.7,
"y": 162.9
},
"Lotide": {
"x": 710.7,
"y": 272.5
},
"Sublinks": {
"x": 658.8,
"y": 232.1
},
"RoadHouse": {
"x": 651.7,
"y": 198.9
},
"Pieoneers": {
"x": 739,
"y": 156.1
},
"WordPress MU + ActivityPub": {
"x": 813.3,
"y": 389.7
},
"Emissary": {
"x": 664.6,
"y": 152.9
},
"Hometown": {
"x": 811.4,
"y": 145.4
},
"GlitchSoc": {
"x": 771.8,
"y": 113
},
"Akkoma-FE": {
"x": 772.4,
"y": 190.2
},
"Postmarks": {
"x": 655.5,
"y": 125.8
},
"AodeRelay": {
"x": 675.4,
"y": 208.9
},
"Coracle": {
"x": 315.6,
"y": 234.6
},
"Nostrudel": {
"x": 318,
"y": 174.1
},
"Lume": {
"x": 364.1,
"y": 272.9
},
"Gossip": {
"x": 388.1,
"y": 203.4
},
"Current": {
"x": 394.7,
"y": 231.1
},
"Plebstr": {
"x": 298.4,
"y": 194.9
},
"Nostrpels": {
"x": 362.4,
"y": 203.6
},
"ZapStream": {
"x": 341.6,
"y": 255.3
},
"NostrCheck.me": {
"x": 326.2,
"y": 201.5
},
"NostrMarket": {
"x": 337.9,
"y": 284.2
},
"Prosody": {
"x": 240.4,
"y": 469.9
},
"ejabberd": {
"x": 220.3,
"y": 514.3
},
"Openfire": {
"x": 211.6,
"y": 486.4
},
"Tigase": {
"x": 217.1,
"y": 412
},
"Conversations": {
"x": 224.9,
"y": 439.8
},
"Dino": {
"x": 168.1,
"y": 448.8
},
"Gajim": {
"x": 196.3,
"y": 453
},
"Monal": {
"x": 172.8,
"y": 478.8
},
"Snikket": {
"x": 267.5,
"y": 480.6
},
"Movim": {
"x": 188.3,
"y": 503.6
},
"Jabber": {
"x": 266.1,
"y": 448.9
},
"Bluesky": {
"x": 800.2,
"y": 115.7
},
"Graysky": {
"x": 768.3,
"y": 68.1
},
"Tokimeki": {
"x": 668.1,
"y": 80
},
"Briar": {
"x": 249.3,
"y": 424.1
},
"Jami": {
"x": 248.1,
"y": 504.1
},
"CryptPad": {
"x": 703.9,
"y": 702.7
},
"HedgeDoc": {
"x": 742.1,
"y": 651.8
},
"Immich": {
"x": 762.5,
"y": 675.8
},
"PhotoPrism": {
"x": 728.6,
"y": 684.6
},
"Synapse": {
"x": 670.4,
"y": 678.3
},
"Dendrite": {
"x": 762.8,
"y": 706.6
},
"Conduit": {
"x": 668.4,
"y": 706.8
},
"Cinny": {
"x": 718.7,
"y": 740
},
"FluffyChat": {
"x": 687.7,
"y": 732.6
},
"FediBridge": {
"x": 730.4,
"y": 224.3
},
"ActivityPub Relay": {
"x": 737,
"y": 261.3
},
"Sepia Search": {
"x": 750.9,
"y": 208.9
},
"SimpleX Chat": {
"x": 187.6,
"y": 421.8
},
"Nostr Protocol": {
"x": 343.6,
"y": 224.2
},
"Damus": {
"x": 367.6,
"y": 240.2
},
"Amethyst": {
"x": 315.5,
"y": 266.6
},
"Iris": {
"x": 345.9,
"y": 179.9
},
"Primal": {
"x": 374.4,
"y": 178.3
},
"Habla.news": {
"x": 289.9,
"y": 222.1
},
"Nostr.band": {
"x": 291.8,
"y": 250.6
},
"Snort": {
"x": 389.1,
"y": 259.1
}
}
}
+24 -21
View File
@@ -77,24 +77,16 @@ export const timelineEvents: TimelineEvent[] = [
softwareId: 'misskey', softwareId: 'misskey',
color: '#4CC9F0', color: '#4CC9F0',
}, },
// 2015 // 2016
{ {
year: 2015, year: 2016,
title: 'PeerTube', month: 10,
description: "Framasoft lance PeerTube, une plateforme vidéo fédérée et décentralisée. Alternative libre à YouTube.",
category: 'software',
softwareId: 'peertube',
color: '#F72585',
},
{
year: 2015,
title: 'Mastodon', title: 'Mastodon',
description: "Eugen Rochko lance Mastodon. Le début de l'explosion du Fédiverse moderne. Utilise OStatus, migrera plus tard vers ActivityPub.", description: "Eugen Rochko lance Mastodon. Le début de l'explosion du Fédiverse moderne. Utilise OStatus, migrera plus tard vers ActivityPub.",
category: 'software', category: 'software',
softwareId: 'mastodon', softwareId: 'mastodon',
color: '#4CC9F0', color: '#4CC9F0',
}, },
// 2016
{ {
year: 2016, year: 2016,
title: 'Pleroma', title: 'Pleroma',
@@ -126,6 +118,14 @@ export const timelineEvents: TimelineEvent[] = [
category: 'protocol', category: 'protocol',
color: '#F72585', color: '#F72585',
}, },
{
year: 2017,
title: 'PeerTube',
description: "Framasoft lance PeerTube, une plateforme vidéo fédérée et décentralisée. Alternative libre à YouTube.",
category: 'software',
softwareId: 'peertube',
color: '#F72585',
},
{ {
year: 2017, year: 2017,
title: 'PixelFed', title: 'PixelFed',
@@ -145,6 +145,7 @@ export const timelineEvents: TimelineEvent[] = [
// 2018 // 2018
{ {
year: 2018, year: 2018,
month: 1,
title: 'ActivityPub — W3C Recommendation', title: 'ActivityPub — W3C Recommendation',
description: "ActivityPub devient une recommandation officielle du W3C. Le standard est désormais finalisé.", description: "ActivityPub devient une recommandation officielle du W3C. Le standard est désormais finalisé.",
category: 'milestone', category: 'milestone',
@@ -248,13 +249,6 @@ export const timelineEvents: TimelineEvent[] = [
color: '#06D6A0', color: '#06D6A0',
}, },
// 2021 // 2021
{
year: 2021,
title: 'Twitter → Mastodon',
description: "Vague massive de migrations de Twitter vers Mastodon après l'acquisition par Elon Musk. Le Fédiverse passe de 2M à 10M+ d'utilisateurs.",
category: 'milestone',
color: '#F72585',
},
{ {
year: 2021, year: 2021,
title: 'GoToSocial', title: 'GoToSocial',
@@ -264,6 +258,14 @@ export const timelineEvents: TimelineEvent[] = [
color: '#4CC9F0', color: '#4CC9F0',
}, },
// 2022 // 2022
{
year: 2022,
month: 10,
title: 'Twitter → Mastodon',
description: "Vague massive de migrations de Twitter vers Mastodon après l'acquisition par Elon Musk. Le Fédiverse passe de 2M à 10M+ d'utilisateurs.",
category: 'milestone',
color: '#F72585',
},
{ {
year: 2022, year: 2022,
title: 'Akkoma', title: 'Akkoma',
@@ -352,14 +354,15 @@ export const timelineEvents: TimelineEvent[] = [
softwareId: 'ghost', softwareId: 'ghost',
color: '#7209B7', color: '#7209B7',
}, },
// 2024
{ {
year: 2023, year: 2024,
month: 2,
title: 'Bluesky ouvre au public', title: 'Bluesky ouvre au public',
description: "Bluesky (AT Protocol) ouvre ses portes. L'écosystème des réseaux décentralisés gagne en visibilité.", description: "Bluesky (AT Protocol) ouvre ses portes. L'écosystème des réseaux décentralisés gagne en visibilité.",
category: 'ecosystem', category: 'ecosystem',
color: '#4CC9F0', color: '#4CC9F0',
}, },
// 2024
{ {
year: 2024, year: 2024,
title: 'Loops', title: 'Loops',
@@ -411,7 +414,7 @@ export const timelineEvents: TimelineEvent[] = [
{ {
year: 2026, year: 2026,
title: 'Le Grand Atlas du Fédiverse v4.0', title: 'Le Grand Atlas du Fédiverse v4.0',
description: "Publication de cette cartographie complète recensant 55+ logiciels, 12 protocoles et 18 ans d'histoire du réseau fédéré.", description: "Publication de cette cartographie complète recensant 105 logiciels, 12 protocoles et 18 ans d'histoire du réseau fédéré.",
category: 'milestone', category: 'milestone',
color: '#F72585', color: '#F72585',
}, },
+42
View File
@@ -0,0 +1,42 @@
import { syncopatedDelay } from './tokens';
/**
* Action `use:reveal` — reveal syncopé à l'entrée dans le viewport.
*
* L'état caché initial n'existe que si JS est actif (classe `js` sur
* <html>, posée par theme.js) ET sans prefers-reduced-motion (media
* query dans base.css) : sans JS ou en reduced-motion, le contenu est
* intégralement visible.
*
* @param index position dans la cascade — délai gwoka 3+3+2 (charte §3)
*/
export function reveal(node: HTMLElement, index: number = 0) {
function apply(i: number) {
node.dataset.reveal = '';
node.style.setProperty('--d', `${syncopatedDelay(i)}ms`);
}
apply(index);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
node.classList.add('in');
observer.disconnect();
}
}
},
{ threshold: 0.15, rootMargin: '0px 0px -5% 0px' }
);
observer.observe(node);
return {
update(i: number) {
apply(i);
},
destroy() {
observer.disconnect();
}
};
}
+17
View File
@@ -0,0 +1,17 @@
import { browser } from '$app/environment';
/** Gate unique prefers-reduced-motion (playbook §6). */
export function prefersReducedMotion(): boolean {
return browser && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/**
* Stagger syncopé de marque OKI (3+3+2) — charte §3.
* Délais en ms pour l'index donné (cycle de 8 éléments).
*/
const SYNCOPATED_DELAYS = [0, 120, 300, 360, 600, 660, 900, 960];
export function syncopatedDelay(index: number): number {
const cycle = Math.floor(index / 8);
return SYNCOPATED_DELAYS[index % 8] + cycle * 1200;
}
@@ -0,0 +1,26 @@
/* État partagé pour ouvrir la fiche détail d'un logiciel du catalogue
depuis une autre section (ex. la chronologie).
SoftwareCatalog consomme la demande et affiche sa modale existante. */
import { softwareById, type Software } from '$lib/data';
let pendingRequest = $state<Software | null>(null);
/**
* Demande l'ouverture de la fiche d'un logiciel dans le catalogue.
* Sans effet si l'identifiant n'existe pas dans software.json.
*/
export function requestSoftwareDetails(softwareId: string): void {
const software = softwareById.get(softwareId);
if (software) pendingRequest = software;
}
/**
* Lit et consomme la dernière demande d'ouverture (appelé par SoftwareCatalog).
* Retourne `null` s'il n'y a aucune demande en attente.
*/
export function consumeSoftwareDetailsRequest(): Software | null {
const software = pendingRequest;
pendingRequest = null;
return software;
}
+2 -2
View File
@@ -18,7 +18,7 @@ const worker = self as unknown as ServiceWorkerGlobalScope;
const PRECACHE = `atlas-precache-${version}`; const PRECACHE = `atlas-precache-${version}`;
const RUNTIME = `atlas-runtime-${version}`; const RUNTIME = `atlas-runtime-${version}`;
// Les listes $service-worker incluent le base path (/fediverse) : // Listes $service-worker : build = chunks Vite, files = static/, prerendered = pages HTML.
// build = chunks Vite, files = static/, prerendered = pages HTML prérendues. // build = chunks Vite, files = static/, prerendered = pages HTML prérendues.
const PRECACHE_URLS = [...build, ...files, ...prerendered]; const PRECACHE_URLS = [...build, ...files, ...prerendered];
@@ -67,7 +67,7 @@ async function networkFirst(request: Request): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
// Candidates : copie de la visite, version canonique avec slash final // Candidates : copie de la visite, version canonique avec slash final
// (trailingSlash: 'always' → pages en dossiers), puis la racine du scope // (trailingSlash: 'always' → pages en dossiers), puis la racine du scope
// (registration.scope inclut le base path, ex. https://o-k-i.net/fediverse/). // (registration.scope, ex. https://syel.o-k-i.net/).
const candidates = [ const candidates = [
request.url, request.url,
`${url.origin}${url.pathname.replace(/\/?$/, '/')}`, `${url.origin}${url.pathname.replace(/\/?$/, '/')}`,
+159
View File
@@ -0,0 +1,159 @@
/* ============================================================
OKI BASE — primitives charte OKI (o-k-i.net base.css), fusionnées
avec le socle du projet : reset, corps de texte, focus visible,
skip-link, visually-hidden et kill-switch prefers-reduced-motion
vivent déjà dans reset.css / base.css — non repris ici.
============================================================ */
@layer base {
/* Titres : Archivo, capitales, crénage serré (charte §1.3) */
h1,
h2,
h3,
h4 {
text-transform: uppercase;
letter-spacing: -0.01em;
}
/* États initiaux des reveals : uniquement si JS actif (attribut
data-js posé par +layout.svelte) ET motion autorisée — sans JS ou
en reduced-motion, tout le contenu est visible (playbook §2.6) */
@media (prefers-reduced-motion: no-preference) {
html[data-js] [data-reveal] {
opacity: 0;
transform: translateY(28px);
transition:
opacity var(--dur-mesure) var(--ease-ka) var(--d, 0ms),
transform var(--dur-mesure) var(--ease-ka) var(--d, 0ms);
}
html[data-js] [data-reveal].in {
opacity: 1;
transform: translateY(0);
}
/* Variante « pop » des pictogrammes : scale(0.6) → 1 */
html[data-js] [data-reveal].reveal-pop {
transform: scale(0.6);
transform-origin: center;
}
html[data-js] [data-reveal].reveal-pop.in {
transform: scale(1);
}
}
/* ── Signature n°1 : la flag-bar, 4 segments francs, 6 px ── */
.flag-bar {
height: 6px;
width: 100%;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
/* ── Bouton OKI canonique : Archivo 700, uppercase, bordure 2 px ── */
.btn {
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-block;
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: var(--space-2) var(--space-4);
border: var(--border-btn);
border-radius: var(--radius-sm);
background: transparent;
color: var(--or-oki);
cursor: pointer;
transition:
color var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka),
transform var(--dur-tanbou) var(--ease-ka);
}
/* Balayage or qui glisse depuis la gauche (hover craft) */
.btn::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: var(--or-oki);
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--dur-tanbou) var(--ease-syncope);
}
.btn:hover {
color: var(--noir-oki);
transform: translateY(-2px);
}
.btn:hover::after {
transform: scaleX(1);
}
.btn-solid {
background: var(--or-oki);
color: var(--noir-oki);
}
.btn-solid::after {
background: var(--or-clair);
}
.btn-solid:hover {
border-color: var(--or-clair);
}
/* ── Carte OKI canonique : fond 3 %, liseré gauche 4 px, survol ── */
.card {
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-md);
padding: var(--space-4);
transition:
transform var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka);
}
.card:hover {
transform: translateY(-3px);
border-color: var(--or-oki);
}
/* ── Tag OKI canonique : or 8 % + bordure or 1 px ── */
.tag {
display: inline-block;
background: color-mix(in srgb, var(--or-oki) 8%, transparent);
border: 1px solid var(--or-oki);
border-radius: var(--radius-sm);
padding: 0.15em 0.6em;
font-size: 0.85rem;
color: var(--or-oki);
}
/* ── Pictogrammes SVG (set OKI, stroke 2 px, currentColor) ── */
.icon {
width: 1.5em;
height: 1.5em;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: square;
stroke-linejoin: miter;
flex-shrink: 0;
}
.icon-fill {
fill: currentColor;
stroke: none;
}
}
+73
View File
@@ -0,0 +1,73 @@
/* ============================================================
OKI BRIDGE — alias des anciens tokens du projet vers la charte
OKI (oki-tokens.css). Règles hors layer : elles priment sur les
définitions d'origine (tokens.css, themes/default.css, @layer tokens).
Thème sombre OKI = seule identité : color-scheme dark et valeurs
sombres fixes — les light-dark() d'origine sont court-circuités,
les sélecteurs existants ne changent pas.
============================================================ */
:root {
color-scheme: dark;
/* ── Fonds & surfaces ── */
--surface: var(--noir-oki);
--surface-2: var(--noir-profond);
--surface-3: var(--gris-sombre);
--abyss: var(--noir-oki); /* fond le plus profond (hero, footer) */
--ocean: var(--noir-profond); /* fond intermédiaire */
/* ── Texte ── */
--ink: var(--blanc-creme);
--ink-soft: var(--muted); /* crème 70 % */
--on-accent: var(--noir-oki); /* texte sur bouton or */
/* ── Bordures & verre — filets blanc 10 %, jamais de gris plein ── */
--border: var(--line);
--border-strong: rgba(255, 255, 255, 0.25);
--glass: color-mix(in srgb, var(--noir-oki) 82%, transparent);
--glass-border: var(--line);
/* ── Accents — or = SEULE couleur d'action (liens, CTA, focus) ── */
--accent: var(--or-oki);
--accent-text: var(--or-oki);
--accent-strong: var(--or-oki);
--accent-2: var(--turquoise-caraibes); /* décor uniquement */
--accent-2-text: var(--or-oki); /* ses usages texte (liens) → or */
--accent-3: var(--orange-flamme); /* couleur protocolaire Nostr */
--accent-3-text: var(--orange-flamme);
--accent-3-strong: var(--orange-flamme);
--accent-4: var(--vert-oki); /* couleur protocolaire SimpleX */
--accent-4-text: var(--vert-oki);
--accent-4-strong: var(--vert-oki);
/* ── États sémantiques — rouge = signaler, vert = valider ── */
--status-active: var(--vert-oki);
--status-maintenance: var(--jaune-soleil);
--status-experimental: var(--rouge-oki);
--compat-full: var(--vert-oki);
--compat-partial: var(--jaune-soleil);
--compat-extension: var(--turquoise-caraibes);
--compat-none: var(--rouge-oki);
/* ── Rayons — angles nets partout (charte §1.4) ── */
--radius-1: var(--radius-sm); /* 3px */
--radius-2: var(--radius-md); /* 6px */
--radius-3: var(--radius-md);
--radius-pill: var(--radius-md);
/* ── Ombres & halos ── */
--shadow-1: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.45);
--shadow-2: 0 0.5rem 2rem rgba(0, 0, 0, 0.55);
--glow-accent: 0 0 1.5rem color-mix(in srgb, var(--or-oki) 45%, transparent);
--glow-accent-2: 0 0 1.5rem color-mix(in srgb, var(--or-oki) 30%, transparent);
/* ── Dégradés de marque (texte en background-clip) ── */
--gradient-brand: linear-gradient(135deg, var(--or-oki), var(--or-clair));
--gradient-abyss: linear-gradient(180deg, var(--noir-profond), var(--noir-oki));
/* ── Transitions — cadences gwoka (charte §1.7) ── */
--ease-out: var(--ease-ka);
--duration-1: var(--dur-tanbou);
--duration-2: var(--dur-mesure);
}
+75
View File
@@ -0,0 +1,75 @@
/* ============================================================
OKI TOKENS — charte-oki-design-system.md §2 et §3 (canonique)
Thème sombre = identité par défaut. Le clair est un opt-in
(html.light-theme) qui assombrit les accents pour WCAG AA.
============================================================ */
:root {
/* ── Noyau (thème sombre = défaut) ── */
--noir-oki: #0d0d0d; /* background */
--noir-profond: #1a0f1a; /* surface */
--blanc-creme: #fff8e7; /* foreground */
--line: rgba(255, 255, 255, 0.1); /* filets — jamais de gris plein */
--or-oki: #fdb813; /* accent : SEULE couleur d'action */
--rouge-oki: #ff1654; /* signal, ponctuation — jamais un lien/bouton */
/* ── Étendue (parcimonie : décorations et sémantique) ── */
--vert-oki: #00d66c; /* succès, validation, dons mensuels */
--turquoise-caraibes: #00ced1;
--jaune-soleil: #ffd700;
--orange-flamme: #ff6b35;
--violet-nuit: #6b2d5c;
--bleu-ocean: #0077be;
--or-clair: #ffe066; /* survol des boutons */
--vert-clair: #66ffaa;
--rouge-clair: #ff6b9d;
--gris-sombre: #2d1b2e;
/* ── Sémantique dérivée ── */
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
--card-bg: rgba(255, 255, 255, 0.03);
/* ── Typographie ── */
--font-display: 'Archivo', 'Arial Black', sans-serif; /* 600-900 : TITRES + BOUTONS */
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; /* 400-800 */
/* ── Formes, espacements, layout — angles nets partout ── */
--radius-sm: 3px; /* boutons, tags, badges */
--radius-md: 6px; /* cartes — jamais plus */
--border-card: 1px solid var(--line);
--border-btn: 2px solid var(--or-oki);
--space-1: 0.75rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 4rem;
--container: 1200px;
/* ── Motion — cadences gwoka (charte §3) ── */
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1); /* temps fort : attaque franche, longue tenue */
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1); /* contretemps : symétrique, urgent */
--dur-tanbou: 120ms; /* double-croche — micro-interactions, hovers */
--dur-mesure: 480ms; /* une mesure — entrées de section */
--dur-phrase: 960ms; /* deux mesures — transitions de page */
}
/* Thème clair — opt-in, accents assombris pour WCAG AA */
html.light-theme {
--noir-oki: #fff8e7;
--blanc-creme: #0d0d0d;
--noir-profond: #f5f0e8;
--gris-sombre: #e8ddd0;
--or-oki: #b87a00;
--vert-oki: #006b3d;
--rouge-oki: #a01030;
--turquoise-caraibes: #006b75;
--violet-nuit: #6b2d5c;
--bleu-ocean: #004b7f;
--or-clair: #d99000;
--vert-clair: #008c4f;
--rouge-clair: #c01545;
--jaune-soleil: #b87a00;
--orange-flamme: #c85000;
--line: rgba(0, 0, 0, 0.12);
--card-bg: rgba(0, 0, 0, 0.03);
}
+43
View File
@@ -0,0 +1,43 @@
/* Polices auto-hébergées — charte OKI §1.3.
Archivo 600-900 : titres + boutons · Inter variable : corps.
URLs relatives au fichier (servi depuis /fonts/). */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('archivo-latin-600-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('archivo-latin-700-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('archivo-latin-800-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('archivo-latin-900-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('inter-var.woff2') format('woff2');
}
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
<svg xmlns="http://www.w3.org/2000/svg">
<!-- Set iconographique OKI — charte §4 : viewBox 24×24, stroke 2 px, angles nets, currentColor -->
<symbol id="ka" viewBox="0 0 24 24">
<!-- tambour ka : collerette hexagonale, fût conique, sangles en zigzag -->
<path d="M4 7 L20 7 L22 9 L20 11 L4 11 L2 9 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M4 11 L7 21 L17 21 L20 11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M8 11 L10.5 16 L9 21 M16 11 L13.5 16 L15 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="lambi" viewBox="0 0 24 24">
<!-- conque lambi : dome en spirale, lèvre angulaire -->
<path d="M4 14 C4 8 9 4 14 4 C18 4 21 7 21 11 L17 14 L20 18 L13 20 C8 21 4 18 4 14 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M11 14 C11 12.5 13.5 12.5 13.5 14 C13.5 16 9.5 16 9.5 13 C9.5 9.5 14 8.5 16 10.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="zetwal" viewBox="0 0 24 24">
<!-- étoile 4 branches -->
<path d="M12 3 L14.5 9.5 L21 12 L14.5 14.5 L12 21 L9.5 14.5 L3 12 L9.5 9.5 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="mawon" viewBox="0 0 24 24">
<!-- brisure de chaîne : deux demi-maillons octogonaux, connexion rompue -->
<path d="M9 7 L5 7 L3 9 L3 15 L5 17 L9 17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M15 7 L19 7 L21 9 L21 15 L19 17 L15 17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M12 9.5 L12 11 M12 13 L12 14.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="lakanmou" viewBox="0 0 24 24">
<!-- flamme : contour angulaire à base courbe, languette interne -->
<path d="M12 3 L15 8 L18 12 C19 15 18 18 15.5 20 L12 21 L8.5 20 C6 18 5 15 6 12 L9 8 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M12 12 L14 14.5 C14.3 16.5 13 18 12 18 C11 18 9.7 16.5 10 14.5 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="jaden" viewBox="0 0 24 24">
<!-- pousse : tige et deux feuilles angulaires en cerf-volant -->
<path d="M12 21 L12 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M12 13 L3 5 L6 12 L10 16 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M12 13 L21 5 L18 12 L14 16 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="kannen" viewBox="0 0 24 24">
<!-- canne à sucre : tige segmentée, trois lames au sommet -->
<path d="M10 21 L10 6 L14 6 L14 21 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M10 17 L14 17 M10 13 L14 13 M10 9 L14 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M10 6 L5 3 M14 6 L19 3 M12 6 L12 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="lanme" viewBox="0 0 24 24">
<!-- vague angulaire : trois lignes brisées superposées -->
<path d="M2 8 L7 4 L12 8 L17 4 L22 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M2 14 L7 10 L12 14 L17 10 L22 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M2 20 L7 16 L12 20 L17 16 L22 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="glo" viewBox="0 0 24 24">
<!-- poing levé géométrique : bloc poing + avant-bras, doigts segmentés, pouce -->
<path d="M8 20 L8 15 L6 15 L6 6 L8 4 L18 4 L18 15 L16 15 L16 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M6 9 L18 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M9 4 L9 9 M12 4 L12 9 M15 4 L15 9 M6 12 L10 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="kle" viewBox="0 0 24 24">
<!-- cadenas ouvert : corps rectangulaire, anse angulaire levée -->
<path d="M5 11 L19 11 L19 21 L5 21 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M8 11 L8 6 L10 4 L14 4 L16 6 L16 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M12 14.5 L12 17.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="pawol" viewBox="0 0 24 24">
<!-- bulle de parole angulaire : rectangle à queue, deux lignes de texte -->
<path d="M3 4 L21 4 L21 15 L13 15 L8 20 L8 15 L3 15 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M7 8 L17 8 M7 11.5 L13 11.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
<symbol id="mizik-note" viewBox="0 0 24 24">
<!-- double croche : deux tiges reliées par un croc, têtes en losange -->
<path d="M9 6 L9 19 M19 4 L19 17 M9 6 L19 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M9 16.5 L11.5 19 L9 21.5 L6.5 19 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M19 14.5 L21.5 17 L19 19.5 L16.5 17 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

+2 -2
View File
@@ -7,8 +7,8 @@
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#001219", "background_color": "#0D0D0D",
"theme_color": "#001219", "theme_color": "#0D0D0D",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://syel.o-k-i.net/sitemap.xml
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://syel.o-k-i.net/</loc>
<lastmod>2026-07-21</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://syel.o-k-i.net/nostr/</loc>
<lastmod>2026-07-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://syel.o-k-i.net/simplex/</loc>
<lastmod>2026-07-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>