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:
Generated
+33
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} /> 
|
||||
<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]}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} /> 
|
||||
<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} /> 
|
||||
<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
|
||||
>
|
||||
|
||||
@@ -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} /> 
|
||||
<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} /> 
|
||||
<span class="accent-text"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} /> 
|
||||
<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>
|
||||
|
||||
@@ -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} /> 
|
||||
<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
|
||||
>
|
||||
|
||||
@@ -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} /> 
|
||||
<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]}
|
||||
|
||||
@@ -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} /> 
|
||||
<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
|
||||
>
|
||||
|
||||
@@ -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} /> 
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user