/* 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 = ''; 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' } }); });