Mode clair / sombre et thèmes de saison
This commit is contained in:
parent
da05adede1
commit
17cf2c8380
19 changed files with 3563 additions and 0 deletions
258
assets/js/effects.js
vendored
Normal file
258
assets/js/effects.js
vendored
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/* effects.js : Scroll inertiel, nav auto-masquée, progression, rail, reveals,
|
||||
scroll horizontal + skew, blob, wipe de page, menu mobile. Vanilla JS.
|
||||
Base : handoff/theme-assets/effects.js (design v3), adapté au site :
|
||||
- animations inconditionnelles (choix de Yasser) : seul ?ybstatic=1
|
||||
(html.yb-static, posé dans header.php) les coupe, pour les captures ;
|
||||
- le wipe couvre tous les liens internes, pas seulement [data-wipe] ;
|
||||
- barres de compétences [data-bar], compteur des travaux = nb réel de cartes. */
|
||||
const YB_STATIC = document.documentElement.classList.contains('yb-static');
|
||||
const YB_TOUCH = matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
/* Scroll inertiel (façon Lenis) : desktop uniquement ; sur mobile on garde le scroll natif. */
|
||||
window.YBSmooth = { v: 0, init() {
|
||||
if (YB_STATIC || YB_TOUCH) return;
|
||||
let cur = scrollY, tgt = scrollY, wt = 0;
|
||||
addEventListener('wheel', e => {
|
||||
if (e.ctrlKey || document.body.classList.contains('no-scroll')) return;
|
||||
e.preventDefault();
|
||||
const max = document.documentElement.scrollHeight - innerHeight;
|
||||
tgt = Math.max(0, Math.min(max, tgt + e.deltaY)); wt = performance.now();
|
||||
}, { passive: false });
|
||||
const tick = () => {
|
||||
if (performance.now() - wt < 1600) { cur += (tgt - cur) * 0.095; scrollTo(0, cur); }
|
||||
else { cur = tgt = scrollY; }
|
||||
this.v += ((tgt - cur) - this.v) * 0.12; // vélocité, utilisée pour le skew
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}};
|
||||
|
||||
/* Nav qui se masque en descendant + barre de progression lime en haut */
|
||||
window.YBNav = { init(navSel, progSel) {
|
||||
const nav = document.querySelector(navSel), prog = document.querySelector(progSel);
|
||||
let last = scrollY;
|
||||
const tick = () => {
|
||||
const y = scrollY, max = Math.max(1, document.documentElement.scrollHeight - innerHeight);
|
||||
if (prog) prog.style.transform = 'scaleX(' + Math.min(1, y / max) + ')';
|
||||
if (nav && !document.body.classList.contains('no-scroll')) {
|
||||
if (y - last > 1.5 && y > 180) nav.style.transform = 'translateY(-104%)';
|
||||
else if (y - last < -1.5 || y <= 180) nav.style.transform = 'translateY(0)';
|
||||
}
|
||||
last = y; requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}};
|
||||
|
||||
/* Rail vertical : chaque item [data-rail-target="#section"] s'allume quand sa section passe à mi-écran */
|
||||
window.YBRail = { init(railSel) {
|
||||
const rail = document.querySelector(railSel); if (!rail) return;
|
||||
const items = [...rail.querySelectorAll('[data-rail-target]')];
|
||||
const tick = () => {
|
||||
let act = 0;
|
||||
items.forEach((it, i) => { const s = document.querySelector(it.dataset.railTarget); if (s && s.getBoundingClientRect().top <= innerHeight * 0.5) act = i; });
|
||||
items.forEach((it, i) => it.classList.toggle('is-active', i === act));
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}};
|
||||
|
||||
/* Reveals au scroll : [data-reveal] ; les barres [data-bar] se remplissent à leur entrée */
|
||||
window.YBReveals = { init() {
|
||||
const all = document.querySelectorAll('[data-reveal], [data-bar]');
|
||||
if (YB_STATIC) { all.forEach(el => el.classList.add('is-in')); return; }
|
||||
const io = new IntersectionObserver(es => es.forEach(en => {
|
||||
if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); }
|
||||
}), { threshold: 0.12 });
|
||||
all.forEach(el => io.observe(el));
|
||||
}};
|
||||
|
||||
/* Scroll horizontal des travaux (desktop/tablette) + skew selon la vélocité + compteur 01/06.
|
||||
Mobile (<720px) : CSS bascule la piste en scroll-snap horizontal natif (voir effects.css). */
|
||||
window.YBHScroll = { init(secSel, trackSel, countSel) {
|
||||
const sec = document.querySelector(secSel), track = document.querySelector(trackSel), count = document.querySelector(countSel);
|
||||
if (!sec || !track || YB_STATIC) return;
|
||||
const total = Math.max(1, track.querySelectorAll('.card').length);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const tick = () => {
|
||||
if (innerWidth >= 720) {
|
||||
const r = sec.getBoundingClientRect();
|
||||
const prog = Math.max(0, Math.min(1, -r.top / (r.height - innerHeight)));
|
||||
const dist = Math.max(0, track.scrollWidth - innerWidth);
|
||||
const skew = Math.max(-4.5, Math.min(4.5, (YBSmooth.v || 0) * 0.011));
|
||||
track.style.transform = 'translateX(' + (-prog * dist) + 'px) skewX(' + (-skew) + 'deg)';
|
||||
if (count) count.textContent = pad(Math.min(total, 1 + Math.floor(prog * total))) + ' / ' + pad(total);
|
||||
} else track.style.transform = '';
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}};
|
||||
|
||||
/* Blob lumineux : suit la souris dans la zone, fondu + rétrécit quand elle sort */
|
||||
window.YBBlob = { init(zoneSel, blobSel) {
|
||||
const zone = document.querySelector(zoneSel), blob = document.querySelector(blobSel);
|
||||
if (!zone || !blob || YB_TOUCH || YB_STATIC) { if (blob) blob.style.display = 'none'; return; }
|
||||
const m = { x: 0, y: 0, bx: 0, by: 0, bo: 0 };
|
||||
addEventListener('mousemove', e => { m.x = e.clientX; m.y = e.clientY; });
|
||||
const tick = () => {
|
||||
const r = zone.getBoundingClientRect();
|
||||
m.bx += (m.x - r.left - m.bx) * 0.06; m.by += (m.y - r.top - m.by) * 0.06;
|
||||
const inside = m.x >= r.left && m.x <= r.right && m.y >= r.top - 60 && m.y <= r.bottom + 60;
|
||||
m.bo += ((inside ? 1 : 0) - m.bo) * 0.08;
|
||||
blob.style.transform = 'translate(' + m.bx + 'px,' + m.by + 'px) scale(' + (0.6 + m.bo * 0.4) + ')';
|
||||
blob.style.opacity = m.bo.toFixed(3);
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}};
|
||||
|
||||
/* Hero : légère parallaxe du bloc titre à la souris (desktop) */
|
||||
window.YBHeroParallax = { init(sel) {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el || YB_TOUCH || YB_STATIC) return;
|
||||
const m = { x: innerWidth / 2, y: innerHeight / 2, cx: innerWidth / 2, cy: innerHeight / 2 };
|
||||
addEventListener('mousemove', e => { m.x = e.clientX; m.y = e.clientY; });
|
||||
const tick = () => {
|
||||
m.cx += (m.x - m.cx) * 0.25; m.cy += (m.y - m.cy) * 0.25;
|
||||
if (el.getBoundingClientRect().bottom > 0) el.style.transform = 'translate(' + (m.cx - innerWidth / 2) * -0.012 + 'px,' + (m.cy - innerHeight / 2) * -0.008 + 'px)';
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}};
|
||||
|
||||
/* Wipe de page : rideau lime. Le rideau (.yb-wipe) est rendu par header.php et
|
||||
couvre l'écran dès le premier paint (html.is-entering) ; il remonte une fois la page prête.
|
||||
Toute navigation interne le fait redescendre ; le libellé vient de data-wipe et
|
||||
survit au changement de page via sessionStorage (clé ybWipe). */
|
||||
/* Couleur des barres du navigateur (meta theme-color) : fond du thème, accent pendant le rideau */
|
||||
window.YBBar = (function () {
|
||||
let meta, wiping = false;
|
||||
const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||
const set = c => { if (meta && c && meta.content !== c) meta.content = c; };
|
||||
return {
|
||||
accent() { wiping = true; set(css('--lime')); },
|
||||
base() { wiping = false; set(css('--bg')); },
|
||||
init() {
|
||||
meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }
|
||||
addEventListener('yb:theme', () => setTimeout(() => wiping ? this.accent() : this.base(), 0));
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
window.YBWipe = { init() {
|
||||
let w = document.querySelector('.yb-wipe');
|
||||
if (!w) {
|
||||
w = document.createElement('div');
|
||||
w.className = 'yb-wipe';
|
||||
w.innerHTML = '<span class="yb-wipe__label"></span>';
|
||||
document.body.appendChild(w);
|
||||
}
|
||||
const label = w.querySelector('.yb-wipe__label');
|
||||
try { sessionStorage.removeItem('ybWipe'); } catch (e) {}
|
||||
if (!YB_STATIC) { YBBar.accent(); setTimeout(() => YBBar.base(), 520); } else YBBar.base();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => document.documentElement.classList.remove('is-entering')));
|
||||
if (YB_STATIC) return;
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target.closest('a[href]');
|
||||
if (!a || a.target === '_blank' || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.charAt(0) === '#' || /^(mailto:|tel:|javascript:)/i.test(href)) return;
|
||||
if (a.host !== location.host) return;
|
||||
if (/\/wp-(admin|login)/.test(a.pathname) || /\.(pdf|zip|png|jpe?g|webp|svg|gif)$/i.test(a.pathname)) return;
|
||||
if (a.pathname === location.pathname && a.hash) return;
|
||||
e.preventDefault();
|
||||
const txt = a.dataset.wipe || '';
|
||||
try { sessionStorage.setItem('ybWipe', txt); } catch (err) {}
|
||||
label.textContent = txt;
|
||||
w.classList.add('is-covering'); YBBar.accent();
|
||||
setTimeout(() => { location.href = a.href; }, 480);
|
||||
});
|
||||
/* Retour arrière depuis le cache (bfcache) : ne pas rester bloqué sous le rideau */
|
||||
addEventListener('pageshow', e => { if (e.persisted) { w.classList.remove('is-covering'); YBBar.base(); } });
|
||||
}};
|
||||
|
||||
/* Coordonnées protégées contre la collecte automatique : [data-pv] (texte), [data-pv-href] (lien),
|
||||
[data-pv-copy] (bouton copier). Encodage côté PHP : ROT13 → inversion → base64.
|
||||
Décodage au premier geste humain (souris, toucher, clavier, focus, scroll). */
|
||||
window.YBProtect = (function () {
|
||||
let done = false;
|
||||
const rot13 = t => t.replace(/[a-z]/gi, c => { const b = c <= 'Z' ? 65 : 97; return String.fromCharCode((c.charCodeAt(0) - b + 13) % 26 + b); });
|
||||
const dec = v => { try { return rot13(decodeURIComponent(escape(atob(v))).split('').reverse().join('')); } catch (e) { return ''; } };
|
||||
function reveal() {
|
||||
if (done) return; done = true;
|
||||
document.querySelectorAll('[data-pv]').forEach(el => { el.textContent = dec(el.dataset.pv); el.removeAttribute('data-pv'); });
|
||||
document.querySelectorAll('[data-pv-href]').forEach(el => { el.setAttribute('href', dec(el.dataset.pvHref)); el.removeAttribute('data-pv-href'); });
|
||||
document.querySelectorAll('[data-pv-copy]').forEach(el => { el.dataset.copy = dec(el.dataset.pvCopy); el.removeAttribute('data-pv-copy'); });
|
||||
EVENTS.forEach(ev => removeEventListener(ev, reveal, true));
|
||||
}
|
||||
const EVENTS = ['pointermove', 'pointerdown', 'touchstart', 'keydown', 'focusin', 'wheel', 'scroll'];
|
||||
return { reveal, init() {
|
||||
if (!document.querySelector('[data-pv],[data-pv-href],[data-pv-copy]')) return;
|
||||
if (YB_STATIC) return reveal();
|
||||
EVENTS.forEach(ev => addEventListener(ev, reveal, { capture: true, passive: true }));
|
||||
/* Un clic sur un lien protégé avant décodage : on décode puis on relance le clic */
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target.closest('[data-pv-href]');
|
||||
if (!a) return;
|
||||
e.preventDefault(); reveal(); a.click();
|
||||
}, true);
|
||||
} };
|
||||
})();
|
||||
|
||||
/* Thème clair / sombre : bouton [data-theme-toggle], choix mémorisé (localStorage yb-theme).
|
||||
Bascule animée : un cercle s'étend depuis le bouton (View Transitions), fondu sinon. */
|
||||
window.YBTheme = { init() {
|
||||
const btn = document.querySelector('[data-theme-toggle]'); if (!btn) return;
|
||||
const root = document.documentElement;
|
||||
const cur = () => root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
const label = () => btn.setAttribute('aria-label', cur() === 'light' ? 'Passer en mode sombre' : 'Passer en mode clair');
|
||||
const apply = t => {
|
||||
root.setAttribute('data-theme', t);
|
||||
try { localStorage.setItem('yb-theme', t); } catch (e) {}
|
||||
label();
|
||||
dispatchEvent(new CustomEvent('yb:theme', { detail: t }));
|
||||
};
|
||||
label();
|
||||
btn.addEventListener('click', () => {
|
||||
const next = cur() === 'light' ? 'dark' : 'light';
|
||||
const r = btn.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
|
||||
if (!document.startViewTransition || YB_STATIC) {
|
||||
root.classList.add('theme-fade'); apply(next);
|
||||
setTimeout(() => root.classList.remove('theme-fade'), 500);
|
||||
return;
|
||||
}
|
||||
const end = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||
const vt = document.startViewTransition(() => apply(next));
|
||||
vt.ready.then(() => root.animate(
|
||||
{ clipPath: ['circle(0px at ' + x + 'px ' + y + 'px)', 'circle(' + end + 'px at ' + x + 'px ' + y + 'px)'] },
|
||||
{ duration: 750, easing: 'cubic-bezier(.7,0,.3,1)', pseudoElement: '::view-transition-new(root)' }
|
||||
));
|
||||
});
|
||||
}};
|
||||
|
||||
/* Menu mobile plein écran : bouton [data-menu-toggle], panneau [data-menu] */
|
||||
window.YBMenu = { init() {
|
||||
const btn = document.querySelector('[data-menu-toggle]'), panel = document.querySelector('[data-menu]');
|
||||
if (!btn || !panel) return;
|
||||
const set = open => {
|
||||
panel.classList.toggle('is-open', open);
|
||||
document.body.classList.toggle('no-scroll', open);
|
||||
btn.textContent = open ? 'FERMER ✕' : 'MENU';
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
panel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
||||
};
|
||||
btn.addEventListener('click', () => set(!panel.classList.contains('is-open')));
|
||||
panel.querySelectorAll('a').forEach(a => a.addEventListener('click', () => set(false)));
|
||||
addEventListener('keydown', e => { if (e.key === 'Escape' && panel.classList.contains('is-open')) set(false); });
|
||||
}};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
YBBar.init(); YBProtect.init(); YBTheme.init(); YBSmooth.init(); YBReveals.init(); YBWipe.init(); YBMenu.init();
|
||||
YBNav.init('.site-nav', '.scroll-progress');
|
||||
YBRail.init('.section-rail');
|
||||
YBHScroll.init('#travaux', '.works-track', '.works-count');
|
||||
YBBlob.init('#apropos', '.about-blob');
|
||||
YBHeroParallax.init('.hero__content');
|
||||
if (window.YBCursor) YBCursor.init();
|
||||
if (window.YBOrb) YBOrb.init('#orb-canvas', { sections: { works: '#travaux', about: '#apropos', cta: '#cta' } });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue