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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user