feat: passe expérience — motion system niveau Awwwards (DOM/SVG, zéro WebGL)

- IntroLoader : cérémonie d'entrée (flag-bar, monogramme, titrage), 1re visite, skippable
- Lenis au layout (ticker GSAP, imports dynamiques) + ancres interceptées
- Hero : titrage oversized en masques, filets drapeau en parallaxe, sortie scrubée (scroll-driven CSS)
- Reveal syncopé gwoka (use:reveal) sur 83 éléments, flag chips, icônes pop
- Marquee kréyòl (tagline + valeurs), pause au survol
- Hover craft : boutons à balayage or, nav soulignée + section active, pochettes DJANGOKAM en wipe clip-path + tilt 3D (use:tilt) avec reflet or
- Gate unique prefers-reduced-motion partout ; contenu complet sans JS
- Lighthouse mobile 91/100/100/100 — JS initial 69 Ko gzip (budget 170 Ko)
This commit is contained in:
sucupira
2026-07-21 12:42:02 -04:00
parent 30858926fa
commit a6f359559b
27 changed files with 1018 additions and 66 deletions
+33 -1
View File
@@ -11,7 +11,8 @@
"dependencies": {
"@fontsource/archivo": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"gsap": "^3.15.0"
"gsap": "^3.15.0",
"lenis": "^1.3.25"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
@@ -5046,6 +5047,37 @@
"node": ">=6"
}
},
"node_modules/lenis": {
"version": "1.3.25",
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.25.tgz",
"integrity": "sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==",
"license": "MIT",
"workspaces": [
"packages/*",
"playground",
"playground/*"
],
"funding": {
"type": "github",
"url": "https://github.com/sponsors/darkroomengineering"
},
"peerDependencies": {
"@nuxt/kit": ">=3.0.0",
"react": ">=17.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+2 -1
View File
@@ -25,6 +25,7 @@
"dependencies": {
"@fontsource/archivo": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"gsap": "^3.15.0"
"gsap": "^3.15.0",
"lenis": "^1.3.25"
}
}
+52 -2
View File
@@ -1,16 +1,40 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { alternatePath, type Bundle, type Locale } from '$lib/i18n';
let { t, locale }: { t: Bundle; locale: Locale } = $props();
let menuOpen = $state(false);
let openDropdown = $state<string | null>(null);
let activeSection = $state('');
const altLocale: Locale = $derived(locale === 'fr' ? 'en' : 'fr');
const altHref = $derived(alternatePath(page.url.pathname, altLocale));
/** Extrait l'ancre d'une URL de nav (`/#solutions` → `solutions`). */
function anchorOf(url: string): string {
const i = url.indexOf('#');
return i >= 0 ? url.slice(i + 1) : '';
}
// Section courante surlignée : la bande médiane du viewport désigne l'actif
onMount(() => {
const sections = [...document.querySelectorAll('section[id]')];
if (sections.length === 0) return;
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) activeSection = entry.target.id;
}
},
{ rootMargin: '-40% 0px -55% 0px' }
);
for (const section of sections) io.observe(section);
return () => io.disconnect();
});
function rememberLangChoice() {
localStorage.setItem('oki-lang-pref', altLocale);
}
@@ -78,7 +102,12 @@
</li>
{:else}
<li>
<a href={resolve(item.url)} onclick={closeMenu}>{item.text}</a>
<a
href={resolve(item.url)}
class:active={anchorOf(item.url) !== '' && anchorOf(item.url) === activeSection}
aria-current={anchorOf(item.url) === activeSection ? 'true' : undefined}
onclick={closeMenu}>{item.text}</a
>
</li>
{/if}
{/each}
@@ -177,15 +206,36 @@
}
.nav-links a {
position: relative;
color: var(--blanc-creme);
font-weight: 500;
font-size: 0.95rem;
}
.nav-links a:hover {
/* Souligné or qui se dessine depuis la gauche (hover + section active) */
.nav-links a::after {
content: '';
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 2px;
background: var(--or-oki);
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--dur-tanbou) var(--ease-syncope);
}
.nav-links a:hover,
.nav-links a.active {
color: var(--or-oki);
}
.nav-links a:hover::after,
.nav-links a.active::after {
transform: scaleX(1);
}
.lang-switch {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import { reveal } from '$lib/motion/reveal';
/**
* FlagChip — ornement de marque au-dessus des titres de section.
* Version réduite de la flag-bar (charte : ≤ 1 flag-bar pleine largeur
* par écran) : 4 segments francs noir/or/vert/rouge, 56 × 6 px.
* Se dessine en scaleX 0 → 1 (origin left) à l'entrée du viewport via
* use:reveal — état caché uniquement si html.js ET motion autorisée.
* Les titres de section de l'accueil sont tous centrés : le chip aussi.
*/
</script>
<span class="flag-chip" aria-hidden="true" use:reveal></span>
<style>
.flag-chip {
display: block;
width: 56px;
height: 6px;
margin: 0 auto var(--space-2);
background: linear-gradient(
to right,
#0d0d0d 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
:global(html.light-theme) .flag-chip {
background: linear-gradient(
to right,
#000 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
/* Dessin scaleX : remplace la translation du reveal de base.
La transition (dur-mesure, ease-ka, délai --d) vient de base.css. */
@media (prefers-reduced-motion: no-preference) {
:global(html.js) .flag-chip {
transform: scaleX(0);
transform-origin: left center;
}
:global(html.js) .flag-chip:global(.in) {
transform: scaleX(1);
}
}
</style>
@@ -0,0 +1,216 @@
<script lang="ts">
import { onMount } from 'svelte';
/**
* IntroLoader — cérémonie d'entrée (P5 : chaque état est un moment designé).
*
* Visibilité pilotée par la classe `intro-pending` posée par theme.js
* (externe, CSP-safe) : première visite de la session, JS actif, pas de
* reduced-motion. Sans ces conditions, l'overlay est `display: none` et
* la page est complète en dessous. La chorégraphie est 100 % CSS
* (keyframes à délais syncopés) ; le JS ne gère que la sortie et le skip.
*/
const DURATION_MS = 1500; // début de la sortie (keyframes CSS)
const EXIT_MS = 480; // durée de la sortie (--dur-mesure)
function finish() {
sessionStorage.setItem('oki-intro', '1');
document.documentElement.classList.remove('intro-pending');
}
function skip() {
finish();
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') skip();
}
onMount(() => {
if (!document.documentElement.classList.contains('intro-pending')) return;
document.addEventListener('keydown', onKeydown);
// Fin de séquence : la sortie CSS est déjà jouée, on nettoie l'état.
const timer = setTimeout(finish, DURATION_MS + EXIT_MS + 100);
return () => {
clearTimeout(timer);
document.removeEventListener('keydown', onKeydown);
};
});
</script>
<div class="intro-loader" role="dialog" aria-label="Introduction">
<div class="intro-inner">
<div class="intro-flags" aria-hidden="true">
<span class="seg-noir"></span>
<span class="seg-or"></span>
<span class="seg-vert"></span>
<span class="seg-rouge"></span>
</div>
<img src="/images/logo-512x512.png" alt="" class="intro-logo" width="96" height="96" />
<p class="intro-title" aria-hidden="true">
<span class="intro-word w1">ORGANISATION KA</span>
<span class="intro-word w2">INTERNATIONALE</span>
</p>
<p class="intro-ka" lang="gcf">An nou maché</p>
</div>
<button type="button" class="intro-skip" onclick={skip}>Passer l'intro</button>
</div>
<style>
.intro-loader {
display: none;
}
/* Chorégraphie : uniquement quand theme.js a posé intro-pending */
:global(html.intro-pending) .intro-loader {
display: flex;
position: fixed;
inset: 0;
z-index: 2000;
background: var(--noir-oki);
align-items: center;
justify-content: center;
animation: intro-out var(--dur-mesure) var(--ease-ka) 1.5s forwards;
}
.intro-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
text-align: center;
padding: 0 var(--space-4);
}
.intro-flags {
display: flex;
width: min(420px, 70vw);
height: 6px;
}
.intro-flags span {
flex: 1;
transform: scaleX(0);
transform-origin: left center;
animation: flag-draw 300ms var(--ease-ka) forwards;
}
.intro-flags span:nth-child(2) {
animation-delay: 100ms;
}
.intro-flags span:nth-child(3) {
animation-delay: 200ms;
}
.intro-flags span:nth-child(4) {
animation-delay: 300ms;
}
.seg-noir {
background: #0d0d0d;
outline: 1px solid var(--line);
}
.seg-or {
background: var(--or-oki);
}
.seg-vert {
background: var(--vert-oki);
}
.seg-rouge {
background: var(--rouge-oki);
}
.intro-logo {
width: 96px;
height: 96px;
opacity: 0;
transform: translateY(20px);
animation: rise var(--dur-mesure) var(--ease-ka) 450ms forwards;
}
.intro-title {
font-family: var(--font-display);
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.01em;
line-height: 1.05;
font-size: clamp(1.6rem, 5vw, 3rem);
}
.intro-word {
display: block;
opacity: 0;
transform: translateY(110%);
animation: rise var(--dur-mesure) var(--ease-ka) forwards;
}
.w1 {
animation-delay: 700ms;
}
.w2 {
color: var(--or-oki);
animation-delay: 820ms;
}
.intro-ka {
color: var(--muted);
font-size: 0.95rem;
opacity: 0;
animation: fade var(--dur-mesure) var(--ease-ka) 1100ms forwards;
}
.intro-skip {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
background: none;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--muted);
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
cursor: pointer;
opacity: 0;
animation: fade 300ms var(--ease-ka) 800ms forwards;
transition:
color var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka);
}
.intro-skip:hover {
color: var(--or-oki);
border-color: var(--or-oki);
}
@keyframes flag-draw {
to {
transform: scaleX(1);
}
}
@keyframes rise {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade {
to {
opacity: 1;
}
}
@keyframes intro-out {
to {
transform: translateY(-100%);
visibility: hidden;
}
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import type { Bundle } from '$lib/i18n';
/**
* Marquee kréyòl — bande cinétique typographique.
* Défilement 100 % CSS (contenu dupliqué aria-hidden pour la boucle),
* pause au hover/focus, coupée en reduced-motion (gate globale base.css
* + règle dédiée ci-dessous).
*/
let { t }: { t: Bundle } = $props();
const items: string[] = $derived([
t.site.tagline,
...t.values.map((v: { title: string }) => v.title),
'ORGANISATION KA INTERNATIONALE'
]);
</script>
<div class="marquee" aria-hidden="true">
<div class="marquee-track">
{#each [0, 1] as copy (copy)}
<div class="marquee-group">
{#each items as item, i (i)}
<span class="marquee-item">{item}</span>
<svg class="icon marquee-star" aria-hidden="true"><use href="/icons.svg#zetwal" /></svg>
{/each}
</div>
{/each}
</div>
</div>
<style>
.marquee {
overflow: hidden;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: var(--noir-profond);
padding: var(--space-2) 0;
}
.marquee-track {
display: flex;
width: max-content;
animation: marquee-scroll 36s linear infinite;
}
.marquee:hover .marquee-track,
.marquee:focus-within .marquee-track {
animation-play-state: paused;
}
.marquee-group {
display: flex;
align-items: center;
flex-shrink: 0;
}
.marquee-item {
font-family: var(--font-display);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: clamp(1.1rem, 2.5vw, 1.6rem);
color: var(--muted);
white-space: nowrap;
padding: 0 var(--space-3);
transition: color var(--dur-tanbou) var(--ease-ka);
}
.marquee:hover .marquee-item {
color: var(--blanc-creme);
}
.marquee-item:first-child {
color: var(--or-oki);
}
.marquee-star {
width: 1rem;
height: 1rem;
color: var(--or-oki);
flex-shrink: 0;
}
@keyframes marquee-scroll {
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee-track {
animation: none;
flex-wrap: wrap;
width: 100%;
justify-content: center;
}
/* Pas de doublon à l'arrêt */
.marquee-group:last-child {
display: none;
}
}
</style>
+11 -2
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import ResponsiveImage from '$lib/components/ResponsiveImage.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import kaubuntuCom from '$lib/assets/images/clients/kaubuntu-com.png?format=avif;webp;png&w=320;640&as=picture';
import kaubuntuRe from '$lib/assets/images/clients/kaubuntu-re.png?format=avif;webp;png&w=320;640&as=picture';
@@ -18,6 +20,7 @@
<section id="clients" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.clients.section_title} />&#32;
<span class="accent-text"
@@ -27,8 +30,14 @@
<p class="section-subtitle">{t.clients.section_subtitle}</p>
<div class="clients-grid">
{#each t.clients.items as client (client.domain)}
<a href={client.url} target="_blank" rel="external noopener noreferrer" class="client-card">
{#each t.clients.items as client, i (client.domain)}
<a
href={client.url}
target="_blank"
rel="external noopener noreferrer"
class="client-card"
use:reveal={i}
>
<div class="client-logo-container">
<ResponsiveImage
picture={LOGOS[client.logo]}
+8 -4
View File
@@ -1,8 +1,10 @@
<script lang="ts">
// {@html} sur contenu first-party (JSON i18n du dépôt), aucune donnée utilisateur.
import BrandIcon from '$lib/components/icons/BrandIcon.svelte';
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import { splitLeadingEmoji } from './pictos';
let { t }: { t: Bundle; locale?: Locale } = $props();
@@ -35,16 +37,17 @@
<section class="section contact" id="contact">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.contact.title} />
</h2>
<!-- Contenu éditorial du bundle i18n (lien mailto balisé). -->
<p class="contact-lede">{@html t.contact.lede}</p>
<p class="contact-lede" use:reveal={0}>{@html t.contact.lede}</p>
</div>
<div class="contact-socials container">
{#each t.contact.sections as group (group.title)}
{#each t.contact.sections as group, i (group.title)}
{@const title = splitLeadingEmoji(group.title)}
<div class="contact-socials-group">
<div class="contact-socials-group" use:reveal={i}>
<h3 class="contact-socials-title">
{#if title.icon}
<svg class="icon" aria-hidden="true"><use href="/icons.svg#{title.icon}" /></svg>
@@ -52,7 +55,7 @@
{title.text}
</h3>
<div class="contact-socials-row">
{#each group.links as link (link.url)}
{#each group.links as link, j (link.url)}
{@const icon = iconFor(link.text)}
<a
href={link.url}
@@ -60,6 +63,7 @@
rel={link.url.startsWith('http') ? 'noopener noreferrer' : undefined}
class="contact-social-link"
aria-label={link.text}
use:reveal={j + 1}
>
{#if icon}
<BrandIcon name={icon} />
+92 -16
View File
@@ -1,8 +1,11 @@
<script lang="ts">
// {@html} sur contenu first-party (JSON i18n du dépôt), aucune donnée utilisateur.
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import ResponsiveImage from '$lib/components/ResponsiveImage.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import { tilt } from '$lib/motion/tilt';
import { pictoFor } from './pictos';
import portrait from '$lib/assets/images/djangokam-portrait.webp?format=avif;webp&w=480;960&as=picture';
@@ -22,6 +25,7 @@
<section id="djangokam" class="section">
<div class="container">
<p class="djangokam-surtitre">{t.djangokam.surtitre}</p>
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.djangokam.titre} />&#32;
<span class="accent-text"
@@ -30,16 +34,21 @@
</h2>
<p class="section-subtitle">{t.djangokam.accroche}</p>
<ResponsiveImage
picture={portrait}
alt="Portrait officiel de DJANGOKAM"
sizes="220px"
class="djangokam-portrait"
/>
<div class="djangokam-portrait-wrap" use:reveal={0}>
<ResponsiveImage
picture={portrait}
alt="Portrait officiel de DJANGOKAM"
sizes="220px"
class="djangokam-portrait"
/>
</div>
<blockquote class="djangokam-highlight">
{t.djangokam.message_cle}
</blockquote>
<!-- Masque vertical : la citation monte depuis le bas (dur-phrase) -->
<div class="djangokam-highlight-mask" use:reveal={1}>
<blockquote class="djangokam-highlight">
{t.djangokam.message_cle}
</blockquote>
</div>
<div class="djangokam-histoire-wrapper">
<div class="djangokam-histoire">
@@ -48,7 +57,7 @@
{/each}
</div>
<figure class="djangokam-duo">
<figure class="djangokam-duo" use:reveal={1} use:tilt>
<a href={t.djangokam.duo.url} target="_blank" rel="external noopener noreferrer">
<ResponsiveImage
picture={POCHETTES[t.djangokam.duo.image]}
@@ -61,9 +70,9 @@
</div>
<div class="djangokam-facts">
{#each t.djangokam.faits as fait (fait.chiffre)}
<div class="djangokam-fact-card">
<div class="djangokam-fact-icon">
{#each t.djangokam.faits as fait, i (fait.chiffre)}
<div class="djangokam-fact-card" use:reveal={i}>
<div class="djangokam-fact-icon reveal-pop" use:reveal={i + 1}>
<svg class="icon" aria-hidden="true"
><use href="/icons.svg#{pictoFor(fait.icon)}" /></svg
>
@@ -76,8 +85,8 @@
</div>
<div class="djangokam-clips">
{#each t.djangokam.clips as clip (clip.titre)}
<figure class="djangokam-clip">
{#each t.djangokam.clips as clip, i (clip.titre)}
<figure class="djangokam-clip" use:reveal={i} use:tilt>
<a href={clip.url} target="_blank" rel="external noopener noreferrer">
<ResponsiveImage
picture={POCHETTES[clip.image]}
@@ -147,9 +156,15 @@
margin: 1rem auto 2rem;
}
.djangokam-highlight {
/* Wrapper-masque de la citation : reprend les marges du blockquote
(le masque doit rogner la translation, pas laisser les marges visibles) */
.djangokam-highlight-mask {
max-width: 800px;
margin: 2rem auto;
overflow: hidden;
}
.djangokam-highlight {
padding: 2rem;
text-align: left;
font-size: 1.1rem;
@@ -187,9 +202,33 @@
.djangokam-clip a {
color: inherit;
display: inline-block;
position: relative;
transition: transform var(--dur-tanbou) var(--ease-ka);
}
/* Reflet or discret qui pivote avec le tilt (--ry hérité de la figure) */
.djangokam-duo a::after,
.djangokam-clip a::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-md);
background: linear-gradient(
calc(105deg + var(--ry, 0deg) * 4),
transparent 35%,
color-mix(in srgb, var(--or-oki) 70%, transparent) 50%,
transparent 65%
);
opacity: 0;
transition: opacity var(--dur-tanbou) var(--ease-ka);
pointer-events: none;
}
.djangokam-duo a:hover::after,
.djangokam-clip a:hover::after {
opacity: 0.15;
}
.djangokam-duo a:hover,
.djangokam-clip a:hover {
transform: translateY(-4px);
@@ -304,6 +343,43 @@
text-decoration: underline;
}
/* ── Chorégraphie d'entrée (même gate que le reveal global : html.js
+ motion autorisée — sinon tout est visible sans transition) ── */
@media (prefers-reduced-motion: no-preference) {
/* Pochettes : wipe clip-path (masque DOM) + tilt 3D piloté par use:tilt.
La transformation de base du reveal est neutralisée : seul le
clip-path révèle, la perspective reste disponible en permanence. */
:global(html.js) .djangokam-duo,
:global(html.js) .djangokam-clip {
opacity: 1;
transform: perspective(700px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
clip-path: inset(0 100% 0 0);
transition:
clip-path var(--dur-mesure) var(--ease-ka) var(--d, 0ms),
transform var(--dur-tanbou) var(--ease-ka);
}
:global(html.js) .djangokam-duo:global(.in),
:global(html.js) .djangokam-clip:global(.in) {
clip-path: inset(0 0 0 0);
}
/* Blockquote lore : le masque ne bouge pas, la citation monte */
:global(html.js) .djangokam-highlight-mask {
opacity: 1;
transform: none;
}
:global(html.js) .djangokam-highlight-mask .djangokam-highlight {
transform: translateY(100%);
transition: transform var(--dur-phrase) var(--ease-ka) var(--d, 0ms);
}
:global(html.js) .djangokam-highlight-mask:global(.in) .djangokam-highlight {
transform: translateY(0);
}
}
@media (max-width: 768px) {
:global(.djangokam-portrait) {
width: 160px;
@@ -1,6 +1,8 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import { pictoFor } from './pictos';
let { t }: { t: Bundle; locale?: Locale } = $props();
@@ -8,6 +10,7 @@
<section id="engagement" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.engagement.section_title} />&#32;
<span class="accent-text"
@@ -17,9 +20,9 @@
<p class="section-subtitle">{t.engagement.section_subtitle}</p>
<div class="engagement-options">
{#each t.engagement.cards as card (card.title)}
<div class="engagement-card">
<div class="engagement-icon">
{#each t.engagement.cards as card, i (card.title)}
<div class="engagement-card" use:reveal={i}>
<div class="engagement-icon reveal-pop" use:reveal={i + 1}>
<svg class="icon" aria-hidden="true"
><use href="/icons.svg#{pictoFor(card.icon)}" /></svg
>
+5 -2
View File
@@ -1,12 +1,15 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
let { t }: { t: Bundle; locale?: Locale } = $props();
</script>
<section id="faq" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.faq.section_title} />&#32;
<span class="accent-text"
@@ -16,8 +19,8 @@
<p class="section-subtitle">{t.faq.section_subtitle}</p>
<div class="faq-container">
{#each t.faq.items as item (item.question)}
<details class="faq-item">
{#each t.faq.items as item, i (item.question)}
<details class="faq-item" use:reveal={i}>
<summary class="faq-question">
<span>{item.question}</span>
<span class="faq-icon" aria-hidden="true">+</span>
@@ -1,4 +1,5 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import HostingVillage from './HostingVillage.svelte';
@@ -8,6 +9,7 @@
<section id="services-libres" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.services.section_title} />&#32;
<span class="accent-text"
+160 -9
View File
@@ -6,9 +6,19 @@
</script>
<section class="hero">
<div class="hero-bg" aria-hidden="true">
<div class="hero-watermark"></div>
<div class="hero-stripes s1"></div>
<div class="hero-stripes s2"></div>
</div>
<div class="hero-content container">
<h1>ORGANISATION KA</h1>
<h2 class="hero-subtitle">INTERNATIONALE</h2>
<h1 class="hero-title">
<span class="mask"><span class="w w1">ORGANISATION</span></span>
<span class="mask"><span class="w w2">KA</span></span>
</h1>
<h2 class="hero-subtitle">
<span class="mask"><span class="w w3">INTERNATIONALE</span></span>
</h2>
<p class="hero-tagline">{t.site.tagline}</p>
<div class="hero-tags">
{#each t.hero.tags as rawTag (rawTag)}
@@ -40,8 +50,13 @@
border-bottom: 1px solid var(--line);
}
.hero::before {
content: '';
.hero-bg {
position: absolute;
inset: 0;
pointer-events: none;
}
.hero-watermark {
position: absolute;
top: 0;
right: -5%;
@@ -49,29 +64,110 @@
height: 100%;
background: url('/images/logo-512x512.png') center / contain no-repeat;
opacity: 0.05;
pointer-events: none;
will-change: transform;
}
/* Filets drapeau en diagonale, très basse opacité (profondeur) */
.hero-stripes {
position: absolute;
left: -50%;
width: 200%;
background: linear-gradient(
to right,
transparent 0 10%,
var(--or-oki) 10% 40%,
var(--vert-oki) 40% 65%,
var(--rouge-oki) 65% 85%,
transparent 85% 100%
);
transform: rotate(-12deg);
will-change: transform;
}
.hero-stripes.s1 {
top: 22%;
height: 3px;
opacity: 0.1;
}
.hero-stripes.s2 {
bottom: 18%;
height: 2px;
opacity: 0.07;
}
.hero-content {
position: relative;
z-index: 2;
width: 100%;
will-change: transform, opacity;
}
h1 {
font-size: clamp(2.2rem, 6vw, 4rem);
font-size: clamp(3rem, 9vw, 7rem);
font-weight: 900;
margin-bottom: 0.4rem;
line-height: 1.05;
line-height: 0.95;
}
.hero-subtitle {
font-size: clamp(1.4rem, 4vw, 2.2rem);
font-size: clamp(1.6rem, 4.5vw, 3rem);
font-weight: 700;
margin-bottom: 1.25rem;
color: var(--or-oki);
}
/* Reveal masqué au chargement (pattern titrage des sites primés) :
uniquement si JS actif et motion autorisée — sinon texte visible */
.mask {
display: inline-block;
overflow: hidden;
vertical-align: bottom;
}
.w {
display: inline-block;
will-change: transform;
}
@media (prefers-reduced-motion: no-preference) {
:global(html.js) .w {
transform: translateY(110%);
animation: word-rise var(--dur-phrase) var(--ease-ka) forwards;
}
:global(html.js) .w1 {
animation-delay: 150ms;
}
:global(html.js) .w2 {
animation-delay: 270ms;
}
:global(html.js) .w3 {
animation-delay: 510ms;
}
/* Si la cérémonie d'intro joue, le titrage attend sa sortie */
:global(html.js.intro-pending) .w1 {
animation-delay: 1400ms;
}
:global(html.js.intro-pending) .w2 {
animation-delay: 1520ms;
}
:global(html.js.intro-pending) .w3 {
animation-delay: 1760ms;
}
}
@keyframes word-rise {
to {
transform: translateY(0);
}
}
.hero-tagline {
font-size: 1.4rem;
margin-bottom: 1.5rem;
@@ -82,7 +178,7 @@
.hero-description {
font-size: 1.2rem;
margin-bottom: 2rem;
opacity: 0.85;
color: var(--muted);
max-width: 680px;
}
@@ -105,6 +201,61 @@
flex-wrap: wrap;
}
/* Parallaxe et sortie scrubée : scroll-driven animations natives,
enhancement progressif — sans support, le hero reste statique */
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.hero-watermark {
animation: wm-drift linear both;
animation-timeline: scroll(root);
animation-range: 0 100vh;
}
.hero-stripes.s1 {
animation: s1-drift linear both;
animation-timeline: scroll(root);
animation-range: 0 100vh;
}
.hero-stripes.s2 {
animation: s2-drift linear both;
animation-timeline: scroll(root);
animation-range: 0 100vh;
}
.hero-content {
animation: hero-exit linear both;
animation-timeline: scroll(root);
animation-range: 0 75vh;
}
}
}
@keyframes wm-drift {
to {
transform: translateY(14%) scale(1.06);
}
}
@keyframes s1-drift {
to {
transform: rotate(-12deg) translateX(-8%);
}
}
@keyframes s2-drift {
to {
transform: rotate(-12deg) translateX(6%);
}
}
@keyframes hero-exit {
to {
transform: translateY(-8vh);
opacity: 0.2;
}
}
@media (max-width: 768px) {
.cta-buttons {
flex-direction: column;
+8 -5
View File
@@ -1,12 +1,15 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
let { t }: { t: Bundle; locale?: Locale } = $props();
</script>
<section id="hebergement" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.hosting.section_title} />&#32;
<span class="accent-text"
@@ -15,7 +18,7 @@
</h2>
<p class="section-subtitle">{t.hosting.section_subtitle}</p>
<div class="hosting-banner">
<div class="hosting-banner" use:reveal={0}>
<h3>{t.hosting.banner_title}</h3>
<p>{t.hosting.banner_text}</p>
@@ -33,8 +36,8 @@
<h3>{t.hosting.offerings_title}</h3>
<div class="offer-cards">
{#each t.hosting.offer_cards as card (card.title)}
<div class="offer-card{card.modifier ? ` offer-card--${card.modifier}` : ''}">
{#each t.hosting.offer_cards as card, i (card.title)}
<div class="offer-card{card.modifier ? ` offer-card--${card.modifier}` : ''}" use:reveal={i}>
<h4>{card.title}</h4>
{#if card.intro}
<p class="offer-intro">{card.intro}</p>
@@ -50,7 +53,7 @@
</div>
</div>
<div class="hosting-note">
<div class="hosting-note" use:reveal={3}>
<h4>{t.hosting.note_title}</h4>
<p>{t.hosting.note_intro}</p>
<ul>
@@ -60,7 +63,7 @@
</ul>
</div>
<div class="hosting-why">
<div class="hosting-why" use:reveal={4}>
<h4>{t.hosting.why_title}</h4>
<p>{t.hosting.why_text}</p>
<a href="#contact" class="btn btn-solid">{t.hosting.why_cta}</a>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
/**
* HostingVillage — scène SVG isométrique « village créole ».
@@ -252,8 +253,8 @@
<!-- ── Fallback : grille de cards (miroir sémantique de la scène) ── -->
<ul class="village-fallback" role="list">
{#each items as item (item.name)}
<li class="card vf-card">
{#each items as item, i (item.name)}
<li class="card vf-card" use:reveal={i}>
<div class="vf-head">
<h3>{item.name}</h3>
<span class="tag">{item.platform}</span>
+6 -3
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import { pictoFor } from './pictos';
let { t }: { t: Bundle; locale?: Locale } = $props();
@@ -8,6 +10,7 @@
<section id="mission" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.mission.section_title} />&#32;
<span class="accent-text"
@@ -18,9 +21,9 @@
<p class="mission-text mission-text--secondary">{t.mission.paragraph_secondary}</p>
<div class="values-grid">
{#each t.values as value (value.title)}
<div class="value-card">
<div class="value-icon">
{#each t.values as value, i (value.title)}
<div class="value-card" use:reveal={i}>
<div class="value-icon reveal-pop" use:reveal={i + 1}>
<svg class="icon" aria-hidden="true"
><use href="/icons.svg#{pictoFor(value.icon)}" /></svg
>
+5 -2
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import ResponsiveImage from '$lib/components/ResponsiveImage.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import joukawouve from '$lib/assets/images/joukawouve.png?format=avif;webp;png&w=320;640&as=picture';
import akv from '$lib/assets/images/akv.png?format=avif;webp;png&w=320;640&as=picture';
@@ -18,6 +20,7 @@
<section id="partenaires" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.partners.section_title} />&#32;
<span class="accent-text"
@@ -27,8 +30,8 @@
<p class="section-subtitle">{t.partners.section_subtitle}</p>
<div class="partners-grid">
{#each t.partners.items as partner (partner.name)}
<div class="partner-card">
{#each t.partners.items as partner, i (partner.name)}
<div class="partner-card" use:reveal={i}>
<div class="partner-logo-container">
<ResponsiveImage
picture={LOGOS[partner.logo]}
+12 -3
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import { pictoFor } from './pictos';
let { t }: { t: Bundle; locale?: Locale } = $props();
@@ -8,6 +10,7 @@
<section id="projets" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.projects.section_title} />&#32;
<span class="accent-text"
@@ -16,9 +19,15 @@
</h2>
<div class="project-cards">
{#each t.projects.items as item (item.name)}
<a href={item.url} target="_blank" rel="external noopener noreferrer" class="project-card">
<div class="project-icon">
{#each t.projects.items as item, i (item.name)}
<a
href={item.url}
target="_blank"
rel="external noopener noreferrer"
class="project-card"
use:reveal={i}
>
<div class="project-icon reveal-pop" use:reveal={i + 1}>
<svg class="icon" aria-hidden="true"
><use href="/icons.svg#{pictoFor(item.icon)}" /></svg
>
+7 -4
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte';
import type { Bundle, Locale } from '$lib/i18n';
import { reveal } from '$lib/motion/reveal';
import { pictoFor } from './pictos';
let { t }: { t: Bundle; locale?: Locale } = $props();
@@ -8,6 +10,7 @@
<section id="solutions" class="section">
<div class="container">
<FlagChip />
<h2 class="section-title">
<KineticText as="span" text={t.publics.section_title} />&#32;
<span class="accent-text"
@@ -17,9 +20,9 @@
<p class="section-subtitle">{t.publics.section_subtitle}</p>
<div class="publics-grid">
{#each t.publics.items as item (item.title)}
<div class="public-card">
<div class="public-icon">
{#each t.publics.items as item, i (item.title)}
<div class="public-card" use:reveal={i}>
<div class="public-icon reveal-pop" use:reveal={i + 1}>
<svg class="icon" aria-hidden="true"
><use href="/icons.svg#{pictoFor(item.icon)}" /></svg
>
@@ -41,7 +44,7 @@
{/each}
</div>
<div class="publics-footer">
<div class="publics-footer" use:reveal={3}>
<p class="publics-footer-lead">{t.publics.footer_line1}</p>
<p>{t.publics.footer_line2}</p>
</div>
+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();
}
};
}
+43
View File
@@ -0,0 +1,43 @@
import type Lenis from 'lenis';
import { prefersReducedMotion } from './tokens';
/**
* SmoothScrollProvider une seule instance Lenis (playbook P1),
* cadencée par le ticker GSAP unique. Gate reduced-motion :
* pas de Lenis si l'utilisateur préfère réduire les animations.
*/
let lenis: Lenis | null = null;
/** Initialise Lenis + ticker GSAP. Retourne la fonction de cleanup. */
export async function initSmoothScroll(): Promise<() => void> {
if (prefersReducedMotion()) return () => {};
const [{ default: LenisClass }, { gsap }] = await Promise.all([import('lenis'), import('gsap')]);
lenis = new LenisClass({ lerp: 0.1 });
const raf = (time: number) => lenis?.raf(time * 1000);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
return () => {
gsap.ticker.remove(raf);
lenis?.destroy();
lenis = null;
};
}
export function getLenis(): Lenis | null {
return lenis;
}
/** Scroll doux vers une ancre de la page courante (compense la nav fixe). */
export function scrollToAnchor(hash: string): boolean {
const target = document.querySelector(hash);
if (!target) return false;
if (lenis) {
lenis.scrollTo(target as HTMLElement, { offset: -80 });
} else {
(target as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' });
}
return true;
}
+42
View File
@@ -0,0 +1,42 @@
import { prefersReducedMotion } from './tokens';
/**
* Action `use:tilt` tilt 3D piloté par le pointeur (transform only).
* Variables `--rx` / `--ry` à consommer dans le CSS du composant :
* `transform: perspective(700px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg))`.
* Désactivé sur tactile (pointer: coarse) et en reduced-motion.
* Écriture dans des variables CSS via rAF jamais d'état réactif par frame.
*/
export function tilt(node: HTMLElement, max: number = 6) {
if (prefersReducedMotion() || window.matchMedia('(pointer: coarse)').matches) return;
let raf = 0;
function onMove(event: PointerEvent) {
const rect = node.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width - 0.5;
const py = (event.clientY - rect.top) / rect.height - 0.5;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
node.style.setProperty('--ry', `${(px * max).toFixed(2)}deg`);
node.style.setProperty('--rx', `${(-py * max).toFixed(2)}deg`);
});
}
function onLeave() {
cancelAnimationFrame(raf);
node.style.setProperty('--rx', '0deg');
node.style.setProperty('--ry', '0deg');
}
node.addEventListener('pointermove', onMove);
node.addEventListener('pointerleave', onLeave);
return {
destroy() {
cancelAnimationFrame(raf);
node.removeEventListener('pointermove', onMove);
node.removeEventListener('pointerleave', onLeave);
}
};
}
+62 -7
View File
@@ -16,6 +16,39 @@ html {
scroll-behavior: smooth;
}
/* Lenis pose .lenis sur <html> : sa glisse prend le relais du smooth natif */
html.lenis {
scroll-behavior: auto;
}
/* États initiaux des reveals : uniquement si JS actif ET motion autorisée
sans JS ou en reduced-motion, tout le contenu est visible (playbook §6) */
@media (prefers-reduced-motion: no-preference) {
html.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.js [data-reveal].in {
opacity: 1;
transform: translateY(0);
}
/* Variante « pop » des pictogrammes : scale(0.6) 1 au lieu de la
translation (même gate, même délai syncopé --d, même transition) */
html.js [data-reveal].reveal-pop {
transform: scale(0.6);
transform-origin: center;
}
html.js [data-reveal].reveal-pop.in {
transform: scale(1);
}
}
body {
font-family: var(--font-body);
background: var(--noir-oki);
@@ -53,10 +86,11 @@ a:hover {
color: var(--or-clair);
}
/* Liens dans le corps de texte : distinguables autrement que par la couleur (WCAG) */
p a:not(.btn),
li a:not(.btn),
blockquote a:not(.btn) {
/* Liens dans le corps de texte : distinguables autrement que par la couleur (WCAG).
Limité au <main> : la nav et le footer ont leurs propres conventions. */
main p a:not(.btn),
main li a:not(.btn),
main blockquote a:not(.btn) {
text-decoration: underline;
text-underline-offset: 0.15em;
}
@@ -125,6 +159,9 @@ section[id] {
/* ── Boutons : Archivo 700, uppercase, bordure 2 px, angles nets ── */
.btn {
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-block;
font-family: var(--font-display);
font-weight: 700;
@@ -137,24 +174,42 @@ section[id] {
color: var(--or-oki);
cursor: pointer;
transition:
background var(--dur-tanbou) var(--ease-ka),
color var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka),
transform var(--dur-tanbou) var(--ease-ka);
}
.btn:hover {
/* 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:hover {
.btn-solid::after {
background: var(--or-clair);
}
.btn-solid:hover {
border-color: var(--or-clair);
}
+30
View File
@@ -4,10 +4,13 @@
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
import Footer from '$lib/components/Footer.svelte';
import Nav from '$lib/components/Nav.svelte';
import IntroLoader from '$lib/components/motion/IntroLoader.svelte';
import ScrollProgressBar from '$lib/components/motion/ScrollProgressBar.svelte';
import { getBundle, localeFromPath } from '$lib/i18n';
import { initSmoothScroll, scrollToAnchor } from '$lib/motion/scroll';
import { prefersReducedMotion } from '$lib/motion/tokens';
let { children } = $props();
@@ -34,9 +37,36 @@
if (to?.url.hash) return;
document.getElementById('contenu')?.focus({ preventScroll: true });
});
onMount(() => {
let cleanupScroll: (() => void) | undefined;
let destroyed = false;
initSmoothScroll().then((fn) => {
if (destroyed) fn();
else cleanupScroll = fn;
});
// Ancres de la page courante : glisse Lenis (playbook P1)
function onClick(event: MouseEvent) {
const anchor = (event.target as HTMLElement).closest('a');
const href = anchor?.getAttribute('href');
if (href?.startsWith('#') && scrollToAnchor(href)) {
event.preventDefault();
history.replaceState(null, '', href);
}
}
document.addEventListener('click', onClick);
return () => {
destroyed = true;
cleanupScroll?.();
document.removeEventListener('click', onClick);
};
});
</script>
<a href="#contenu" class="skip-link">{locale === 'en' ? 'Skip to content' : 'Aller au contenu'}</a>
<IntroLoader />
<ScrollProgressBar />
<Nav {t} {locale} />
<main id="contenu" tabindex="-1">
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import Seo from '$lib/components/Seo.svelte';
import Marquee from '$lib/components/motion/Marquee.svelte';
import Clients from '$lib/components/sections/Clients.svelte';
import Contact from '$lib/components/sections/Contact.svelte';
import Djangokam from '$lib/components/sections/Djangokam.svelte';
@@ -29,5 +30,6 @@
<Clients {t} locale="fr" />
<Fediverse {t} locale="fr" />
<Faq {t} locale="fr" />
<Marquee {t} />
<Engagement {t} locale="fr" />
<Contact {t} locale="fr" />
+2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import Seo from '$lib/components/Seo.svelte';
import Marquee from '$lib/components/motion/Marquee.svelte';
import Clients from '$lib/components/sections/Clients.svelte';
import Contact from '$lib/components/sections/Contact.svelte';
import Djangokam from '$lib/components/sections/Djangokam.svelte';
@@ -29,5 +30,6 @@
<Clients {t} locale="en" />
<Fediverse {t} locale="en" />
<Faq {t} locale="en" />
<Marquee {t} />
<Engagement {t} locale="en" />
<Contact {t} locale="en" />
+10
View File
@@ -1,4 +1,14 @@
(function() {
// Marqueur JS actif : les états initiaux cachés des animations
// (reveal, loader) ne s'appliquent que si JavaScript tourne.
document.documentElement.classList.add('js');
// Cérémonie d'entrée : première visite de la session seulement,
// jamais si l'utilisateur préfère réduire les animations.
if (!sessionStorage.getItem('oki-intro') && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.documentElement.classList.add('intro-pending');
}
// Récupérer le thème stocké ou détecter le thème système
const storedTheme = localStorage.getItem('oki-theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;