diff --git a/app/components/Header.tsx b/app/components/Header.tsx index f77ede1..0a185b1 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -38,7 +38,7 @@ export function Header() { return (
{ const section = rootRef.current?.closest(".hero"); @@ -42,6 +44,7 @@ export default function HeroField() { const p = Math.min(1, Math.max(0, -rect.top / span)); progressRef.current = p; section.style.setProperty("--hero-p", p.toFixed(4)); + setDone(p >= 0.999); }; const queue = () => { if (!frame) frame = requestAnimationFrame(update); @@ -92,7 +95,7 @@ export default function HeroField() { {mount && !failed && ( diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts index e6f1927..08abd38 100644 --- a/app/components/cinematic/useCinematicScroll.ts +++ b/app/components/cinematic/useCinematicScroll.ts @@ -10,20 +10,25 @@ type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) * Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada * para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada * "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais. + * + * Regras que mantêm tudo estável: + * - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1); + * - nenhum ancestral de um recebe scale (o R3F mede o canvas com getBoundingClientRect); + * - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências. * Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh). */ const vh = () => window.innerHeight; +// origem no meio da parte visível da seção que sai (a última altura de tela dela) +const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`; -/** Topo da seção na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */ +/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */ export function pageTop(el: HTMLElement) { let top = 0; for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) top += node.offsetTop; return top; } -// origem no meio da parte visível da seção que sai (a última altura de tela dela) -const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`; // a seção que sai fica parada na tela enquanto a próxima entra const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) => @@ -31,28 +36,29 @@ const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) => // a seção que entra também fica parada no topo: a transição acontece "no lugar" const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) => tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0); +const stageOf = (section: HTMLElement) => + section.querySelector(".notebook-story-sticky") ?? section; // uma linguagem diferente por passagem, na ordem das seções da home const TRANSITIONS: Transition[] = [ - // hero → notebook: o mergulho termina no escuro e o notebook assenta vindo de perto + // hero → notebook: o mergulho termina no escuro, o hero some e o notebook abre como tela de cinema (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); - // o hero some antes de o notebook aparecer: sem texto sobre texto (no celular o hero não esmaece antes) tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo( - next.querySelector(".notebook-story-sticky") ?? next, - { scale: 1.22, opacity: 0 }, - { scale: 1, opacity: 1, duration: 0.65 }, + stageOf(next), + { opacity: 0, clipPath: "inset(32% 0% 32% 0%)" }, + { opacity: 1, clipPath: "inset(0% 0% 0% 0%)", duration: 0.65 }, 0.35, ); }, - // notebook → projetos: a seção sobe como um cartão e o notebook recua para o fundo + // notebook → projetos: a seção sobe como um cartão e o notebook recolhe ao fundo (tl, prev, next) => { holdPrev(tl, prev); tl.fromTo( - prev, - { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, - { scale: 0.86, opacity: 0.15 }, + stageOf(prev), + { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }, + { opacity: 0.15, clipPath: "inset(8% 6% 8% 6% round 32px)" }, 0, ).fromTo( next, @@ -65,23 +71,23 @@ const TRANSITIONS: Transition[] = [ (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); + const center = () => `at 50% ${vh() / 2}px`; tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 1.18, opacity: 0.2 }, 0, ) + // power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem .fromTo( next, - { clipPath: () => `circle(0px at 50% ${vh() / 2}px)` }, + { clipPath: () => `circle(0px ${center()})` }, { - clipPath: () => - `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px at 50% ${vh() / 2}px)`, - duration: 0.96, + clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`, + ease: "power1.in", }, 0, - ) - .set(next, { clipPath: "none" }, 1); + ); }, // modalidades → IA: corte lateral, uma tela empurra a outra (tl, prev, next) => { @@ -90,13 +96,15 @@ const TRANSITIONS: Transition[] = [ tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo( next, { xPercent: 100 }, - { xPercent: 0 }, + { xPercent: 0, ease: "power2.out" }, 0, ); }, - // IA → qualidade: a próxima tomba para a frente, em perspectiva + // IA → qualidade: a tela se abre do centro como duas portas, e o diagrama se desenha. + // Só 2D: com rotação 3D o Chrome adiava a pintura do diagrama (camada grande demais). (tl, prev, next) => { holdPrev(tl, prev); + holdNext(tl, next); tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, @@ -104,26 +112,38 @@ const TRANSITIONS: Transition[] = [ 0, ).fromTo( next, - { rotationX: 28, scale: 0.88, transformPerspective: 1600, transformOrigin: "50% 0%" }, - { rotationX: 0, scale: 1 }, + { clipPath: () => `inset(0px 50% ${next.offsetHeight - vh()}px 50%)` }, + { clipPath: () => `inset(0px 0% ${next.offsetHeight - vh()}px 0%)`, ease: "power2.inOut" }, 0, ); + const lines = next.querySelectorAll( + ".quality-dimensions path, .quality-browser-frame, .quality-blueprint > path.quality-wire, .quality-blueprint > rect.quality-wire, .quality-blueprint > g.quality-wire > *, .quality-browser-button", + ); + const labels = next.querySelectorAll( + ".quality-blueprint text, .quality-browser-dots, .quality-visual-label, .quality-visual figcaption", + ); + if (lines.length) { + tl.set(lines, { attr: { pathLength: 1 }, strokeDasharray: 1 }, 0).fromTo( + lines, + { strokeDashoffset: 1 }, + { strokeDashoffset: 0, duration: 0.4, stagger: { amount: 0.25 } }, + 0.3, + ); + } + if (labels.length) { + tl.fromTo(labels, { opacity: 0 }, { opacity: 1, duration: 0.25, stagger: { amount: 0.2 } }, 0.5); + } }, // qualidade → sobre: cortina diagonal (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); - tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 8 }, 0) - .fromTo( - next, - { clipPath: () => `polygon(0% 0px, 0% 0px, -30% ${vh()}px, -30% ${vh()}px)` }, - { - clipPath: () => `polygon(0% 0px, 130% 0px, 100% ${vh()}px, -30% ${vh()}px)`, - duration: 0.96, - }, - 0, - ) - .set(next, { clipPath: "none" }, 1); + tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 8 }, 0).fromTo( + next, + { clipPath: () => `polygon(0% 0px, 0% 0px, -30% ${vh()}px, -30% ${vh()}px)` }, + { clipPath: () => `polygon(0% 0px, 130% 0px, 100% ${vh()}px, -30% ${vh()}px)` }, + 0, + ); }, // sobre → contato: o contato cresce do centro, como um zoom de câmera (tl, prev, next) => { @@ -134,19 +154,23 @@ const TRANSITIONS: Transition[] = [ { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 0.92, opacity: 0.2 }, 0, - ).fromTo( - next, - { - scale: 0.55, - clipPath: "inset(0% round 56px)", - transformOrigin: () => `50% ${vh() / 2}px`, - }, - { scale: 1, clipPath: "inset(0% round 0px)" }, - 0, - ).fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0); + ) + .fromTo( + next, + { + scale: 0.55, + clipPath: "inset(0% round 56px)", + transformOrigin: () => `50% ${vh() / 2}px`, + }, + { scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" }, + 0, + ) + .fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0); }, ]; +const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); + export function useCinematicScroll(mainRef: RefObject) { useEffect(() => { const main = mainRef.current; @@ -188,14 +212,85 @@ export function useCinematicScroll(mainRef: RefObject) { end: () => pageTop(next), scrub: true, invalidateOnRefresh: true, + // passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente + onLeave: () => gsap.set(next, { clipPath: "none" }), }, }); transition(tl, prev, next); }); }, main); + // Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na + // tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior; + // depois não aparece atrás de transparências, e o navegador não pinta o que está coberto. + let tops: number[] = []; + const syncVisibility = () => { + const y = window.scrollY; + scenes.forEach((scene, index) => { + const from = index === 0 ? -Infinity : tops[index] - vh(); + const to = index === scenes.length - 1 ? Infinity : tops[index + 1]; + const visibility = y >= from && y <= to ? "" : "hidden"; + if (scene.style.visibility !== visibility) scene.style.visibility = visibility; + }); + }; + const measure = () => { + tops = scenes.map(pageTop); + syncVisibility(); + }; + measure(); + ScrollTrigger.addEventListener("refresh", measure); + window.addEventListener("scroll", syncVisibility, { passive: true }); + + // botões e links internos: em vez do salto, a página "avança o vídeo" até o destino + const onClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + const link = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self")) return; + const url = new URL(link.href); + if (url.origin !== location.origin || url.pathname !== location.pathname || !url.hash) + return; + let target: HTMLElement | null = null; + try { + target = document.getElementById(decodeURIComponent(url.hash.slice(1))); + } catch {} + if (!target) return; + event.preventDefault(); + // nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo; + // alvos internos descontam o cabeçalho fixo + const scene = scenes.find((candidate) => candidate.contains(target)); + const offset = + Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0; + const top = + !scene || scene === scenes[0] + ? 0 + : Math.max(pageTop(scene), scene === target ? 0 : pageTop(target) - offset); + if (location.hash !== url.hash) history.pushState(null, "", url.hash); + lenis.scrollTo(top, { + duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000), + easing: easeInOutCubic, + // o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco + onComplete: () => { + if (!target.hasAttribute("tabindex")) target.tabIndex = -1; + target.focus({ preventScroll: true }); + }, + }); + }; + document.addEventListener("click", onClick, true); + cleanup = () => { + document.removeEventListener("click", onClick, true); + ScrollTrigger.removeEventListener("refresh", measure); + window.removeEventListener("scroll", syncVisibility); context.revert(); + for (const scene of scenes) scene.style.visibility = ""; gsap.ticker.remove(tick); lenis.destroy(); }; diff --git a/app/globals.css b/app/globals.css index 48fa3a4..055c35c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2315,3 +2315,48 @@ html.lenis { transition: none; } } + +/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual + recua e desfoca, a nova chega de baixo. O cabeçalho tem nome próprio e fica parado durante a troca. */ +.site-header { + view-transition-name: site-header; +} + +::view-transition-group(site-header) { + animation: none; +} + +::view-transition-group(page) { + animation: none; +} + +::view-transition-old(page) { + animation: page-out 280ms cubic-bezier(0.7, 0, 0.84, 0) both; +} + +::view-transition-new(page) { + animation: page-in 640ms var(--ease-out) 160ms both; +} + +@keyframes page-out { + to { + opacity: 0; + transform: scale(0.97); + filter: blur(8px); + } +} + +@keyframes page-in { + from { + opacity: 0; + transform: translateY(40px) scale(1.015); + filter: blur(10px); + } +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(page), + ::view-transition-new(page) { + animation: none; + } +} diff --git a/app/template.tsx b/app/template.tsx new file mode 100644 index 0000000..6f0c6e3 --- /dev/null +++ b/app/template.tsx @@ -0,0 +1,6 @@ +import { ViewTransition } from "react"; + +/** O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css). */ +export default function Template({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/react-view-transition.d.ts b/react-view-transition.d.ts new file mode 100644 index 0000000..62e37c7 --- /dev/null +++ b/react-view-transition.d.ts @@ -0,0 +1,8 @@ +// O App Router usa a versão canary do React, que exporta ; os tipos estáveis (@types/react 19.1) +// só trazem o nome provisório unstable_ViewTransition. Remover quando os tipos acompanharem. +import type { ExoticComponent, ViewTransitionProps } from "react"; +import type {} from "react/experimental"; + +declare module "react" { + export const ViewTransition: ExoticComponent; +}