From def1e22302663f920c4a666da006bd9f9e91b4f1 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Mon, 5 Oct 2026 01:19:48 -0300 Subject: [PATCH] perf(home): transicoes de Qualidade so com translate/opacity e titulos por palavra em CSS Medido com trace do Chromium (rasterizacao no mesmo trecho de rolagem): IA->Qualidade 840->558 ms, dentro de Qualidade 34->18 ms, Qualidade->Sobre 819->643 ms. Recorte animado, escala de camadas grandes e o desenho do diagrama a cada quadro saem; o diagrama se desenha uma vez em CSS. TextDiffuseReveal deixa de animar cada letra com desfoque. Co-Authored-By: Claude Opus 5.5 --- .../TextDiffuseReveal.reducedMotion.test.tsx | 81 ----------- .../animations/TextDiffuseReveal.test.tsx | 30 +++++ .../animations/TextDiffuseReveal.tsx | 127 ++++-------------- .../cinematic/useCinematicScroll.ts | 45 ++----- app/globals.css | 53 ++++++++ 5 files changed, 121 insertions(+), 215 deletions(-) delete mode 100644 app/components/animations/TextDiffuseReveal.reducedMotion.test.tsx create mode 100644 app/components/animations/TextDiffuseReveal.test.tsx diff --git a/app/components/animations/TextDiffuseReveal.reducedMotion.test.tsx b/app/components/animations/TextDiffuseReveal.reducedMotion.test.tsx deleted file mode 100644 index 5189e17..0000000 --- a/app/components/animations/TextDiffuseReveal.reducedMotion.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { render } from "@testing-library/react"; -import type { ReactNode } from "react"; - -import { TextDiffuseReveal } from "./TextDiffuseReveal"; - -let mockReducedMotion = false; -let mockInView = true; -let mockAnimations: Array<{ animate?: Record }> = []; -jest.mock("motion/react", () => { - const React = require("react"); - - return { - motion: { - span: ({ - children, - initial: _initial, - animate, - transition: _transition, - onAnimationComplete: _onAnimationComplete, - ...props - }: { - children?: ReactNode; - initial?: unknown; - animate?: Record; - transition?: unknown; - onAnimationComplete?: () => void; - } & Record) => { - mockAnimations.push({ animate }); - return React.createElement("span", props, children); - }, - }, - useInView: () => mockInView, - useReducedMotion: () => mockReducedMotion, - }; -}); - -describe("TextDiffuseReveal reduced motion", () => { - beforeEach(() => { - mockReducedMotion = false; - mockInView = true; - mockAnimations = []; - }); - - it("clears blur and scale when reduced motion resolves after mount", () => { - const { rerender } = render(); - - mockAnimations = []; - mockReducedMotion = true; - rerender(); - - expect(mockAnimations).toHaveLength(6); - expect(mockAnimations).toEqual( - expect.arrayContaining( - Array.from({ length: 6 }, () => - expect.objectContaining({ - animate: { - opacity: 1, - filter: "blur(0px)", - scale: 1, - }, - }), - ), - ), - ); - }); - - it("keeps the first render independent of the reduced-motion preference", () => { - mockReducedMotion = true; - mockInView = false; - - render(); - - expect(mockAnimations[0]).toEqual({ - animate: { - filter: "blur(12px)", - opacity: 0, - scale: 0.9, - }, - }); - }); -}); diff --git a/app/components/animations/TextDiffuseReveal.test.tsx b/app/components/animations/TextDiffuseReveal.test.tsx new file mode 100644 index 0000000..cc611d3 --- /dev/null +++ b/app/components/animations/TextDiffuseReveal.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; + +import { TextDiffuseReveal } from "./TextDiffuseReveal"; + +let mockInView = false; +jest.mock("motion/react", () => ({ useInView: () => mockInView })); + +describe("TextDiffuseReveal", () => { + beforeEach(() => { + mockInView = false; + }); + + it("keeps the whole sentence readable once for assistive technology", () => { + const { container } = render(); + + expect(screen.getByText("Software feito a partir do seu negócio")).toHaveClass("sr-only"); + const words = container.querySelectorAll(".text-reveal-word"); + expect(words).toHaveLength(7); + expect(words[0].closest("[aria-hidden='true']")).not.toBeNull(); + }); + + it("marks the title as shown only after it enters the viewport", () => { + const { container, rerender } = render(); + expect(container.querySelector(".text-reveal")).not.toHaveAttribute("data-shown"); + + mockInView = true; + rerender(); + expect(container.querySelector(".text-reveal")).toHaveAttribute("data-shown"); + }); +}); diff --git a/app/components/animations/TextDiffuseReveal.tsx b/app/components/animations/TextDiffuseReveal.tsx index a0857ee..dc2866d 100644 --- a/app/components/animations/TextDiffuseReveal.tsx +++ b/app/components/animations/TextDiffuseReveal.tsx @@ -1,8 +1,7 @@ "use client"; -import { motion, useInView } from "motion/react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; +import { useInView } from "motion/react"; +import { type CSSProperties, useRef } from "react"; interface TextDiffuseRevealProps { text: string; @@ -12,8 +11,12 @@ interface TextDiffuseRevealProps { amount?: number; } -const EXPO_OUT = [0.16, 1, 0.3, 1] as const; - +/** + * Título que entra palavra por palavra. A animação é CSS (.text-reveal em globals.css) com transparência + * e deslocamento: a placa de vídeo compõe sem redesenhar. A versão anterior animava cada letra com + * desfoque e renderizava o React de novo a cada letra, o que pesava justamente durante as transições. + * Movimento reduzido: o CSS mostra o texto direto, sem animação. + */ export function TextDiffuseReveal({ text, className = "", @@ -22,111 +25,31 @@ export function TextDiffuseReveal({ amount = 0.3, }: Readonly) { const ref = useRef(null); - const shouldReduceMotion = useReducedMotion(); const isInView = useInView(ref, { once, amount }); - const [staggerDelay, setStaggerDelay] = useState(0.03); - const [completedChars, setCompletedChars] = useState(0); - - useEffect(() => { - const media = window.matchMedia("(max-width: 768px)"); - const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03); - - updateStagger(); - media.addEventListener("change", updateStagger); - return () => media.removeEventListener("change", updateStagger); - }, []); - - const tokens = useMemo(() => { - let offset = 0; - return text - .split(/(\n|\s+)/) - .filter(Boolean) - .map((value) => { - const key = `${offset}:${value}`; - offset += value.length; - return { key, value }; - }); - }, [text]); - const animatedChars = useMemo( - () => - Array.from(text).filter((char) => char !== "\n" && char !== " ").length, - [text], - ); - - useEffect(() => { - if (!isInView) { - setCompletedChars(0); - } - }, [isInView]); - - const hasActiveWillChange = - !shouldReduceMotion && isInView && completedChars < animatedChars; - - let animatedIndex = 0; + let word = 0; return ( {text} ); diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts index c441148..2a032d4 100644 --- a/app/components/cinematic/useCinematicScroll.ts +++ b/app/components/cinematic/useCinematicScroll.ts @@ -85,48 +85,27 @@ const TRANSITIONS: Transition[] = [ 0, ); }, - // 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). + // IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece. + // Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o + // desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha + // uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter). (tl, prev, next) => { holdPrev(tl, prev); - holdNext(tl, next); - tl.fromTo( - prev, - { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, - { scale: 0.9, opacity: 0.25 }, - 0, - ).fromTo( + tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo( next, - { clipPath: () => `inset(0px 50% ${next.offsetHeight - vh()}px 50%)` }, - { clipPath: () => `inset(0px 0% ${next.offsetHeight - vh()}px 0%)`, ease: "power2.inOut" }, + { yPercent: 0, y: () => vh() * 0.35 }, + { y: 0, ease: "power2.out" }, 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 + // qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform) (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); - tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 8 }, 0).fromTo( + tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 20 }, 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)` }, + { xPercent: -100, rotation: -7, transformOrigin: "0% 0%" }, + { xPercent: 0, rotation: 0, ease: "power2.out" }, 0, ); }, @@ -199,6 +178,8 @@ export function useCinematicScroll(mainRef: RefObject) { 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" }), + // diagrama de qualidade: desenha uma vez quando a seção começa a entrar + onEnter: () => next.classList.add("is-drawing"), }, }); transition(tl, prev, next); diff --git a/app/globals.css b/app/globals.css index 1d2c24b..209a73e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1481,6 +1481,39 @@ html.lenis { } } +/* Entrada do diagrama (classe is-drawing posta por useCinematicScroll): linhas se desenham e rótulos + aparecem uma única vez, sem acompanhar cada quadro da rolagem */ +@media (prefers-reduced-motion: no-preference) { + /* só com as transições ativas (html.lenis): sem o script, o diagrama aparece normalmente */ + html.lenis .quality-lab:not(.is-drawing) :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) { + opacity: 0; + } + + .quality-lab.is-drawing .quality-blueprint :is(.quality-dimensions path, .quality-browser-frame, .quality-wire, .quality-browser-button) { + stroke-dasharray: 1800; /* maior que o maior contorno (moldura: ~1600) */ + animation: quality-draw 1.1s var(--ease-out) both; + } + + .quality-lab.is-drawing :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) { + animation: quality-fade 0.6s var(--ease-out) both; + } +} + +@keyframes quality-draw { + from { + stroke-dashoffset: 1800; + } + to { + stroke-dashoffset: 0; + } +} + +@keyframes quality-fade { + from { + opacity: 0; + } +} + @keyframes quality-trace { from { stroke-dashoffset: 1; @@ -1786,3 +1819,23 @@ html.lenis { animation: none; } } + +/* Títulos de seção (TextDiffuseReveal): entram palavra por palavra só com transparência e deslocamento, + que a placa de vídeo compõe sem redesenhar */ +.text-reveal-word { + display: inline-block; +} + +@media (prefers-reduced-motion: no-preference) { + .text-reveal-word { + transition: + opacity 0.7s var(--ease-out), + transform 0.9s var(--ease-out); + transition-delay: calc(var(--d, 0s) + var(--i, 0) * 55ms); + } + + .text-reveal:not([data-shown]) .text-reveal-word { + opacity: 0; + transform: translateY(0.35em); + } +}