perf(movimento): sem tela vazia, passagens em CSS e vitrine leve

Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:50 -03:00
1 parent 10f170abf4
commit 318a764190
77 files changed
+4293 -4951

No files matched your search

@@ -1,64 +0,0 @@
"use client";
import { useInView } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface CounterValueProps {
value: number;
className?: string;
duration?: number;
prefix?: string;
suffix?: string;
once?: boolean;
amount?: number;
}
export function CounterValue({
value,
className = "",
duration = 1200,
prefix = "",
suffix = "",
once = true,
amount = 0.45,
}: Readonly<CounterValueProps>) {
const shouldReduceMotion = useReducedMotion();
const ref = useRef<HTMLSpanElement | null>(null);
const isInView = useInView(ref, { once, amount });
const [displayValue, setDisplayValue] = useState(0);
useEffect(() => {
if (!isInView) return;
if (shouldReduceMotion) {
setDisplayValue(value);
return;
}
const start = performance.now();
let rafId = 0;
const frame = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - (1 - progress) ** 3;
setDisplayValue(Math.round(value * eased));
if (progress < 1) {
rafId = requestAnimationFrame(frame);
}
};
rafId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(rafId);
}, [isInView, value, duration, shouldReduceMotion]);
return (
<span ref={ref} className={className}>
{prefix}
{displayValue}
{suffix}
</span>
);
}
+9 -22
View File
@@ -1,40 +1,27 @@
"use client";
import { motion } from "motion/react";
import { useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { type CSSProperties, useRef } from "react";
import { useInView } from "@/app/hooks/useInView";
interface DrawLineProps {
className?: string;
delay?: number;
}
/** Linha fina que se desenha da esquerda para a direita quando chega à janela (CSS em globals.css, .ds-draw-line). */
export function DrawLine({
className = "",
delay = 0,
}: Readonly<DrawLineProps>) {
const shouldReduceMotion = useReducedMotion();
const [animating, setAnimating] = useState(!shouldReduceMotion);
const ref = useRef<HTMLSpanElement>(null);
const shown = useInView(ref, { amount: 0.7 });
return (
<motion.span
<span
ref={ref}
className={`ds-draw-line ${className}`.trim()}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scaleX: 0 }}
whileInView={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scaleX: 1 }
}
viewport={{ once: true, amount: 0.7 }}
transition={{
duration: shouldReduceMotion ? 0.25 : 0.6,
delay,
ease: [0.16, 1, 0.3, 1],
}}
style={{
transformOrigin: "left",
willChange:
animating && !shouldReduceMotion ? "transform, opacity" : undefined,
}}
onAnimationComplete={() => setAnimating(false)}
data-shown={shown ? "" : undefined}
style={{ "--reveal-delay": `${delay}s` } as CSSProperties}
/>
);
}
@@ -0,0 +1,107 @@
import { act, render, screen } from "@testing-library/react";
import { LazyRevealImage } from "./LazyRevealImage";
/**
* Testes de caminhos-base de LazyRevealImage (complemento de Reveal.test.tsx): a imagem que ainda carrega, o desenho de baixa resolução só para
* imagem importada e os valores padrão de `sizes` e da classe da imagem.
*/
afterEach(() => {
jest.restoreAllMocks();
});
function mockComplete(complete: boolean, naturalWidth: number) {
jest
.spyOn(HTMLImageElement.prototype, "complete", "get")
.mockReturnValue(complete);
jest
.spyOn(HTMLImageElement.prototype, "naturalWidth", "get")
.mockReturnValue(naturalWidth);
}
async function renderImage(ui: React.ReactElement) {
let view: ReturnType<typeof render> | undefined;
await act(async () => {
view = render(ui);
});
if (!view) throw new Error("render failed");
return view;
}
describe("LazyRevealImage", () => {
it("path: not complete yet -> waits for the load event, then marks it", async () => {
mockComplete(false, 0);
const { container } = await renderImage(
<LazyRevealImage src="/foto.webp" alt="Foto" />,
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper).not.toHaveAttribute("data-loaded");
await act(async () => {
screen.getByAltText("Foto").dispatchEvent(new Event("load"));
});
expect(wrapper).toHaveAttribute("data-loaded");
});
it("path: defaults -> lazy loading, the half-width size hint and the cover class on the image", async () => {
const { container } = await renderImage(
<LazyRevealImage src="/foto.webp" alt="Foto" />,
);
const image = screen.getByAltText("Foto");
expect(image).toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("sizes", "(max-width: 1024px) 100vw, 50vw");
expect(image).toHaveClass("object-cover");
expect(container.firstElementChild?.className).toBe(
"ds-lazy-reveal relative",
);
});
it("path: custom sizes and image class -> used instead of the defaults", async () => {
await renderImage(
<LazyRevealImage
src="/foto.webp"
alt="Foto"
sizes="320px"
imageClassName="object-contain"
className="h-40"
/>,
);
const image = screen.getByAltText("Foto");
expect(image).toHaveAttribute("sizes", "320px");
expect(image).toHaveClass("object-contain");
expect(image).not.toHaveClass("object-cover");
});
it("path: an imported image (with a blur) -> the low-resolution preview style is on the image until it loads", async () => {
mockComplete(false, 0);
await renderImage(
<LazyRevealImage
src={{
src: "/importada.png",
width: 40,
height: 20,
blurDataURL: "data:image/png;base64,AAAA",
}}
alt="Importada"
/>,
);
// o jsdom descarta o `background-image` do SVG que o next/image monta, mas o restante do estilo do desfoque fica
expect(screen.getByAltText("Importada").getAttribute("style")).toContain(
"background-size: cover",
);
});
it("path: a path string -> no preview style on the image", async () => {
mockComplete(false, 0);
await renderImage(<LazyRevealImage src="/foto.webp" alt="Foto" />);
expect(
screen.getByAltText("Foto").getAttribute("style") ?? "",
).not.toContain("background-size");
});
});
+14 -26
View File
@@ -1,9 +1,7 @@
"use client";
import { motion } from "motion/react";
import Image, { type StaticImageData } from "next/image";
import { useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { useEffect, useRef, useState } from "react";
interface LazyRevealImageProps {
src: string | StaticImageData;
@@ -13,6 +11,7 @@ interface LazyRevealImageProps {
sizes?: string;
}
/** Imagem que entra com um fade curto quando termina de carregar (CSS em globals.css, .ds-lazy-reveal). */
export function LazyRevealImage({
src,
alt,
@@ -20,31 +19,20 @@ export function LazyRevealImage({
imageClassName = "object-cover",
sizes = "(max-width: 1024px) 100vw, 50vw",
}: Readonly<LazyRevealImageProps>) {
const shouldReduceMotion = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
// uma imagem que já estava no cache termina antes de a página hidratar: o onLoad não vem mais
useEffect(() => {
const image = ref.current?.querySelector("img");
if (image?.complete && image.naturalWidth > 0) setLoaded(true);
}, []);
return (
<motion.div
className={`relative ${className}`.trim()}
initial={
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 1.02 }
}
animate={
loaded
? shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, y: 0, scale: 1 }
: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20, scale: 1.02 }
}
transition={{
duration: shouldReduceMotion ? 0.25 : 0.75,
ease: [0.16, 1, 0.3, 1],
}}
style={{
willChange: !shouldReduceMotion ? "transform, opacity" : undefined,
}}
<div
ref={ref}
className={`ds-lazy-reveal relative ${className}`.trim()}
data-loaded={loaded ? "" : undefined}
>
<Image
src={src}
@@ -56,6 +44,6 @@ export function LazyRevealImage({
onLoad={() => setLoaded(true)}
className={imageClassName}
/>
</motion.div>
</div>
);
}
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockInView = false;
jest.mock("motion/react", () => ({ useInView: () => mockInView }));
jest.mock("@/app/hooks/useInView", () => ({ useInView: () => mockInView }));
describe("TextDiffuseReveal", () => {
beforeEach(() => {
@@ -1,7 +1,7 @@
"use client";
import { useInView } from "motion/react";
import { type CSSProperties, useRef } from "react";
import { useInView } from "@/app/hooks/useInView";
interface TextDiffuseRevealProps {
text: string;
@@ -1,41 +0,0 @@
"use client";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface TopProgressBarProps {
className?: string;
}
export function TopProgressBar({
className = "",
}: Readonly<TopProgressBarProps>) {
const shouldReduceMotion = useReducedMotion();
return (
<div
className={`pointer-events-none fixed inset-x-0 top-0 z-[120] h-[2px] overflow-hidden bg-white/10 ${className}`.trim()}
aria-hidden="true"
>
<motion.div
className="h-full w-1/3 bg-[var(--slash)] shadow-[0_0_12px_rgba(204,255,0,0.85)]"
initial={shouldReduceMotion ? { opacity: 1, x: "0%" } : { x: "-120%" }}
animate={
shouldReduceMotion
? { opacity: 1, x: "0%" }
: { x: ["-120%", "15%", "45%", "110%"] }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 1.5,
ease: [0.16, 1, 0.3, 1],
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 0.1,
}
}
/>
</div>
);
}
@@ -1,3 +0,0 @@
export default function PointCloudScene() {
return <div data-testid="point-cloud-mock"></div>;
}