feat: harden portfolio and add SEO foundations

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-04 19:25:30 -03:00
1 parent b079afdd73
commit 62bcfbd8fa
121 files changed
+7769 -10469

No files matched your search

@@ -0,0 +1,103 @@
import { fireEvent, render, screen } from "@testing-library/react";
import FilmGrain from "./FilmGrain";
import ScrollVelocityText from "./ScrollVelocityText";
import SectionDivider from "./SectionDivider";
function mockReducedMotion(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion") ? matches : false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
describe("static cinematic effects", () => {
beforeEach(() => mockReducedMotion(false));
it("renders grain and every divider without a canvas", () => {
const grain = render(<FilmGrain />);
expect(grain.container.firstElementChild).toHaveAttribute(
"aria-hidden",
"true",
);
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
grain.unmount();
for (const variant of ["gradient", "pulse", "waveform"] as const) {
const divider = render(<SectionDivider variant={variant} />);
expect(divider.container.firstElementChild).toHaveAttribute(
"aria-hidden",
"true",
);
expect(divider.container.querySelector("canvas")).not.toBeInTheDocument();
expect(divider.container.firstElementChild).not.toHaveAttribute(
"tabindex",
);
divider.unmount();
}
});
it("exposes a persistent pause control for an automatic marquee", () => {
render(<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />);
const pause = screen.getByRole("button", { name: "Pausar animação" });
const track = document.getElementById(
pause.getAttribute("aria-controls") ?? "",
);
expect(pause).toHaveAttribute("aria-pressed", "false");
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
expect(
screen.getByText("SOFTWARE ENGINEERING", {
selector: '[aria-hidden="false"]',
}),
).toBeVisible();
expect(screen.getAllByText("SOFTWARE ENGINEERING")).toHaveLength(2);
expect(
screen
.getAllByText("SOFTWARE ENGINEERING")
.slice(1)
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
).toBe(true);
fireEvent.click(pause);
const resume = screen.getByRole("button", { name: "Retomar animação" });
expect(resume).toHaveAttribute("aria-pressed", "true");
expect(track).toHaveStyle({ animationPlayState: "paused" });
fireEvent.click(resume);
expect(
screen.getByRole("button", { name: "Pausar animação" }),
).toHaveAttribute("aria-pressed", "false");
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
});
it("starts static under reduced motion without a redundant control", () => {
mockReducedMotion(true);
render(<ScrollVelocityText text="VAMOS CONVERSAR" direction="right" />);
expect(
screen.queryByRole("button", { name: /animação/i }),
).not.toBeInTheDocument();
const copies = screen.getAllByText("VAMOS CONVERSAR");
expect(copies).toHaveLength(2);
expect(copies[0]).toBeVisible();
expect(copies[0]).toHaveAttribute("aria-hidden", "false");
expect(copies[0].closest("[style]")).toHaveStyle({
animationPlayState: "paused",
});
expect(
copies
.slice(1)
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
).toBe(true);
});
});
+26 -24
View File
@@ -1,5 +1,4 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import CinematicHome from "./CinematicHome";
jest.mock("next/dynamic", () => {
@@ -19,41 +18,44 @@ jest.mock("next/dynamic", () => {
return () => {
const label = labels[index++] ?? "DynamicSection";
return function DynamicMock() {
return <div data-testid={`dynamic-${label}`}>{label}</div>;
return <section data-testid={`home-section-${label}`}>{label}</section>;
};
};
});
jest.mock("../Hero", () => () => <div data-testid="hero" />);
jest.mock("../Projects", () => () => <div data-testid="projects" />);
jest.mock("../story/ReelDirector", () => () => (
<div data-testid="reel-director" />
jest.mock("../Hero", () => () => <section data-testid="home-section-Hero" />);
jest.mock("../Projects", () => () => (
<section data-testid="home-section-Projects" />
));
jest.mock("../story/NotebookStory", () => () => (
<section data-testid="notebook-story" />
<section data-testid="home-section-NotebookStory" />
));
jest.mock(
"./CinematicSection",
() =>
({ children }: { children: ReactNode }) => <>{children}</>,
);
describe("CinematicHome", () => {
it("uses one notebook story and keeps About after it", () => {
render(
it("renders each primary section directly in the published order", () => {
const { container } = render(
<CinematicHome experiences={[]} projects={[]} skillCategories={[]} />,
);
const main = container.querySelector("main");
const sectionNames = [
"Hero",
"NotebookStory",
"About",
"Skills",
"Experience",
"Projects",
"LighthouseMetrics",
"WorkModalities",
"Contact",
];
const sectionIds = sectionNames.map((name) => `home-section-${name}`);
const directSections = Array.from(main?.children ?? []).filter((element) =>
sectionIds.includes(element.getAttribute("data-testid") ?? ""),
);
const story = screen.getByTestId("notebook-story");
const about = screen.getByTestId("dynamic-About");
expect(story).toBeInTheDocument();
expect(screen.queryByTestId("print-reel")).not.toBeInTheDocument();
expect(screen.queryByTestId("scroll-story")).not.toBeInTheDocument();
expect(screen.getAllByTestId("home-section-NotebookStory")).toHaveLength(1);
expect(
Boolean(
story.compareDocumentPosition(about) & Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
directSections.map((section) => section.getAttribute("data-testid")),
).toEqual(sectionIds);
});
});
+12 -29
View File
@@ -3,15 +3,13 @@
import dynamic from "next/dynamic";
import type {
PortfolioExperience,
PortfolioProject,
PortfolioProjectView,
PortfolioSkillCategory,
} from "@/lib/portfolio-data";
import Hero from "../Hero";
import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading";
import NotebookStory from "../story/NotebookStory";
import ReelDirector from "../story/ReelDirector";
import CinematicSection from "./CinematicSection";
const About = dynamic(() => import("../About"), {
loading: () => <SectionLoading message="Carregando..." />,
@@ -46,67 +44,52 @@ export default function CinematicHome({
experiences,
skillCategories,
}: {
projects: PortfolioProject[];
projects: PortfolioProjectView[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
return (
<div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main">
<ReelDirector />
<Hero />
{/* Marquee divider after hero */}
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
<ScrollVelocityText text="SOFTWARE ENGINEERING" baseVelocity={-1.5} />
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
</div>
<NotebookStory projects={projects} />
<CinematicSection effect="parallax-depth">
<About />
</CinematicSection>
<About />
<SectionDivider variant="waveform" />
<CinematicSection effect="clip-reveal">
<Skills categories={skillCategories} />
</CinematicSection>
<Skills categories={skillCategories} />
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
<ScrollVelocityText
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
baseVelocity={1}
direction="right"
/>
</div>
<CinematicSection effect="fade-scale">
<Experience experiences={experiences} />
</CinematicSection>
<Experience experiences={experiences} />
<SectionDivider variant="gradient" />
<CinematicSection effect="rotate-in">
<Projects projects={projects} />
</CinematicSection>
<Projects projects={projects} />
<CinematicSection effect="clip-reveal">
<LighthouseMetrics />
</CinematicSection>
<LighthouseMetrics />
<SectionDivider variant="pulse" />
<CinematicSection effect="parallax-depth">
<WorkModalities />
</CinematicSection>
<WorkModalities />
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText text="VAMOS CONVERSAR" baseVelocity={-2} />
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
</div>
<CinematicSection effect="fade-scale">
<Contact />
</CinematicSection>
<Contact />
</main>
<Footer />
</div>
@@ -2,9 +2,7 @@
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import PageIntro from "./PageIntro";
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
ssr: false,
@@ -16,10 +14,8 @@ export default function CinematicOverlays() {
return (
<>
<PageIntro />
{isHome ? (
<>
<CustomCursor />
<FilmGrain />
<ScrollProgress />
</>
@@ -1,173 +0,0 @@
"use client";
import { type ReactNode, useEffect, useRef } from "react";
interface CinematicSectionProps {
children: ReactNode;
className?: string;
effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in";
id?: string;
}
export default function CinematicSection({
children,
className = "",
effect = "fade-scale",
id,
}: CinematicSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
// Check reduced motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
switch (effect) {
case "fade-scale": {
gsap.set(el, {
opacity: 0,
scale: 0.92,
transformOrigin: "center center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 85%",
end: "top 20%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
scale: 1,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "clip-reveal": {
gsap.set(el, {
clipPath: "inset(15% 5% 15% 5%)",
opacity: 0.3,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 15%",
scrub: 1,
},
});
tl.to(el, {
clipPath: "inset(0% 0% 0% 0%)",
opacity: 1,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "parallax-depth": {
gsap.set(el, {
y: 80,
opacity: 0,
rotateX: 4,
transformPerspective: 1200,
transformOrigin: "center bottom",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 90%",
end: "top 30%",
scrub: 0.6,
},
});
tl.to(el, {
y: 0,
opacity: 1,
rotateX: 0,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "rotate-in": {
gsap.set(el, {
opacity: 0,
rotateY: -5,
x: -60,
transformPerspective: 1000,
transformOrigin: "left center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 25%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
rotateY: 0,
x: 0,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
}
};
void setup();
return () => {
cleanup?.();
};
}, [effect]);
return (
<div ref={sectionRef} className={className} id={id}>
{children}
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
interface CinematicTextProps {
text: string;
className?: string;
tag?: "h1" | "h2" | "h3" | "p" | "span";
delay?: number;
splitBy?: "chars" | "words" | "lines";
}
export default function CinematicText({
text,
className = "",
tag: Tag = "h2",
delay = 0,
splitBy = "chars",
}: CinematicTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const spans = el.querySelectorAll("[data-char]");
if (spans.length === 0) return;
gsap.set(spans, {
opacity: 0,
y: 60,
rotateX: -90,
transformOrigin: "center bottom",
transformPerspective: 600,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
toggleActions: "play none none none",
},
});
tl.to(spans, {
opacity: 1,
y: 0,
rotateX: 0,
duration: 0.8,
ease: "back.out(1.2)",
stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12,
delay,
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, [text, delay, splitBy]);
const renderContent = () => {
if (splitBy === "chars") {
return text.split("").map((char, i) => (
<span
key={`${char}-${i}`}
data-char
className="inline-block will-change-transform"
style={{ whiteSpace: char === " " ? "pre" : undefined }}
>
{char === " " ? "\u00A0" : char}
</span>
));
}
if (splitBy === "words") {
return text.split(" ").map((word, i) => (
<span key={`${word}-${i}`} className="inline-block overflow-hidden">
<span data-char className="inline-block will-change-transform">
{word}
</span>
{i < text.split(" ").length - 1 && "\u00A0"}
</span>
));
}
// lines
return text.split("\n").map((line, i) => (
<span key={`${line}-${i}`} className="block overflow-hidden">
<span data-char className="block will-change-transform">
{line}
</span>
</span>
));
};
return (
<div ref={containerRef} className="overflow-hidden">
<Tag className={className}>{renderContent()}</Tag>
</div>
);
}
@@ -0,0 +1,146 @@
import { act, render, waitFor } from "@testing-library/react";
import createGlobe from "cobe";
import { ControlRoomGlobe } from "./ControlRoomGlobe";
const mockDestroy = jest.fn();
jest.mock("cobe", () => ({
__esModule: true,
default: jest.fn(() => ({ destroy: mockDestroy })),
}));
jest.mock("motion/react", () => ({
useMotionValue: (initial: number) => ({
get: () => initial,
set: jest.fn(),
}),
useSpring: (value: unknown) => value,
}));
let intersectionCallback: IntersectionObserverCallback | undefined;
let resizeCallback: ResizeObserverCallback | undefined;
let measuredWidth = 400;
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setGlobeIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function renderCallback() {
expect(createGlobe).toHaveBeenCalledTimes(1);
const call = jest.mocked(createGlobe).mock.calls[0];
if (!call) throw new Error("Globe was not initialized");
return call[1].onRender;
}
describe("ControlRoomGlobe lifecycle", () => {
beforeEach(() => {
jest.clearAllMocks();
setDocumentHidden(false);
intersectionCallback = undefined;
resizeCallback = undefined;
measuredWidth = 400;
jest
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
.mockImplementation(() => measuredWidth);
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({} as unknown as GPUCanvasContext);
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
unobserve = jest.fn();
}
global.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("pauses for a hidden tab without losing the intersection flag", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
const renderFrame = renderCallback();
const state = { phi: 0, theta: 0, width: 0, height: 0 };
setGlobeIntersects(true);
renderFrame(state);
expect(state.phi).not.toBe(0);
setDocumentHidden(true);
const pausedPhi = state.phi;
renderFrame(state);
expect(state.phi).toBe(pausedPhi);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).not.toBe(pausedPhi);
setGlobeIntersects(false);
const offscreenPhi = state.phi;
setDocumentHidden(true);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).toBe(offscreenPhi);
});
it("keeps one Cobe instance while ResizeObserver updates render dimensions", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
measuredWidth = 520;
const entry = {
contentRect: { width: measuredWidth },
} as ResizeObserverEntry;
act(() => resizeCallback?.([entry], {} as ResizeObserver));
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(createGlobe).toHaveBeenCalledTimes(1);
expect(state.width).toBe(1040);
expect(state.height).toBe(1040);
});
it("keeps the reduced-motion globe static", async () => {
render(<ControlRoomGlobe autoRotate={false} />);
await waitFor(() => expect(createGlobe).toHaveBeenCalled());
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(state).toEqual({ phi: 0, theta: 0, width: 0, height: 0 });
});
});
+34 -50
View File
@@ -2,7 +2,7 @@
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
@@ -47,7 +47,7 @@ export function ControlRoomGlobe({
autoRotate?: boolean;
rotationSpeed?: number;
}) {
const [width, setWidth] = useState(0);
const widthRef = useRef(0);
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
const thetaPhaseRef = useRef(0);
@@ -55,7 +55,7 @@ export function ControlRoomGlobe({
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerYOrigin = useRef<number | null>(null);
const isVisibleRef = useRef(true);
const intersectsRef = useRef(false);
const r = useMotionValue(0);
const rs = useSpring(r, {
@@ -88,57 +88,44 @@ export function ControlRoomGlobe({
};
useEffect(() => {
const onResize = () => {
if (canvasRef.current) {
setWidth(canvasRef.current.offsetWidth);
}
const canvas = canvasRef.current;
if (!canvas) return undefined;
const dpr = config.devicePixelRatio;
const updateWidth = (entry?: ResizeObserverEntry) => {
const nextWidth =
entry?.contentRect.width || canvas.offsetWidth || config.width / dpr;
if (nextWidth > 0) widthRef.current = nextWidth;
};
updateWidth();
window.addEventListener("resize", onResize);
onResize();
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
resizeObserver.observe(canvas);
const observer = new IntersectionObserver(
const intersectionObserver = new IntersectionObserver(
([entry]) => {
isVisibleRef.current = entry.isIntersecting;
intersectsRef.current = entry.isIntersecting;
},
{ rootMargin: "100px" },
);
intersectionObserver.observe(canvas);
if (canvasRef.current) {
observer.observe(canvasRef.current);
}
const handleVisibilityChange = () => {
isVisibleRef.current = !document.hidden;
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const canvas = canvasRef.current;
if (!canvas) {
return () => {
window.removeEventListener("resize", onResize);
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
);
observer.disconnect();
};
}
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
let globe: ReturnType<typeof createGlobe> | null = null;
let globe: { destroy(): void } | null = null;
try {
const supportsWebGL =
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (supportsWebGL) {
const initialRenderWidth = widthRef.current * dpr;
globe = createGlobe(canvas, {
...config,
width: width * 2,
height: width * 2,
width: initialRenderWidth,
height: initialRenderWidth,
onRender: (state) => {
if (!isVisibleRef.current || document.hidden) return;
if (autoRotate && !pointerInteracting.current) {
if (!autoRotate || !intersectsRef.current || document.hidden)
return;
if (!pointerInteracting.current) {
phiRef.current += rotationSpeed;
thetaPhaseRef.current += rotationSpeed * 0.9;
}
@@ -148,8 +135,9 @@ export function ControlRoomGlobe({
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
Math.sin(thetaPhaseRef.current) * 0.35 +
thetaDragRef.current;
state.width = width * 2;
state.height = width * 2;
const renderWidth = widthRef.current * dpr;
state.width = renderWidth;
state.height = renderWidth;
},
});
}
@@ -161,28 +149,24 @@ export function ControlRoomGlobe({
// Sem globo: fallback visível (gradiente do card permanece), página viva.
canvas.style.opacity = "1";
return () => {
window.removeEventListener("resize", onResize);
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
);
observer.disconnect();
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}
setTimeout(() => {
const revealTimer = window.setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = "1";
}
}, 0);
return () => {
window.clearTimeout(revealTimer);
globe.destroy();
window.removeEventListener("resize", onResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
observer.disconnect();
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [rs, config, autoRotate, rotationSpeed, width]);
}, [rs, config, autoRotate, rotationSpeed]);
return (
<div
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { motion, useMotionValue } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
export default function CustomCursor() {
const [isHovering, setIsHovering] = useState(false);
const [isClicking, setIsClicking] = useState(false);
const [hoverText, setHoverText] = useState("");
const [isVisible, setIsVisible] = useState(false);
const cursorX = useMotionValue(0);
const cursorY = useMotionValue(0);
const rafRef = useRef<number>(0);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
cursorX.set(e.clientX);
cursorY.set(e.clientY);
if (!isVisible) setIsVisible(true);
});
},
[cursorX, cursorY, isVisible],
);
useEffect(() => {
// Only show custom cursor on desktop with fine pointer
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
if (!hasFinePointer) return;
const handleDown = () => setIsClicking(true);
const handleUp = () => setIsClicking(false);
const handleLeave = () => setIsVisible(false);
const handleEnter = () => setIsVisible(true);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mousedown", handleDown);
window.addEventListener("mouseup", handleUp);
document.addEventListener("mouseleave", handleLeave);
document.addEventListener("mouseenter", handleEnter);
// Detect hoverable elements
const observer = new MutationObserver(() => {
attachHoverListeners();
});
observer.observe(document.body, { childList: true, subtree: true });
attachHoverListeners();
function attachHoverListeners() {
const hoverables = document.querySelectorAll(
'a, button, [role="button"], input, textarea, select, [data-cursor]',
);
for (const el of hoverables) {
el.addEventListener("mouseenter", handleElementEnter as EventListener);
el.addEventListener("mouseleave", handleElementLeave as EventListener);
}
}
function handleElementEnter(e: Event) {
const el = e.currentTarget as HTMLElement;
setIsHovering(true);
const cursorData = el.getAttribute("data-cursor");
if (cursorData) setHoverText(cursorData);
}
function handleElementLeave() {
setIsHovering(false);
setHoverText("");
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousedown", handleDown);
window.removeEventListener("mouseup", handleUp);
document.removeEventListener("mouseleave", handleLeave);
document.removeEventListener("mouseenter", handleEnter);
observer.disconnect();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [handleMouseMove]);
// Don't render on touch devices
if (
typeof window !== "undefined" &&
!window.matchMedia("(pointer: fine)").matches
) {
return null;
}
return (
<>
<style jsx global>{`
@media (pointer: fine) {
* { cursor: none !important; }
}
`}</style>
{/* Dot cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
style={{
x: cursorX,
y: cursorY,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
scale: isClicking ? 0.5 : isHovering ? 0.5 : 1,
opacity: isVisible ? 1 : 0,
}}
transition={{ duration: 0.15 }}
>
<div
className="rounded-full bg-[#ccff00]"
style={{ width: 8, height: 8 }}
/>
</motion.div>
{/* Ring cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
style={{
x: cursorX,
y: cursorY,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
width: isHovering ? 80 : 40,
height: isHovering ? 80 : 40,
opacity: isVisible ? (isHovering ? 0.8 : 0.4) : 0,
borderWidth: isHovering ? 2 : 1,
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
<div
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
style={{
borderStyle: "solid",
borderColor: "#ccff00",
borderWidth: "inherit",
}}
>
{hoverText && (
<motion.span
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="text-[9px] font-bold uppercase tracking-widest text-[#ccff00]"
>
{hoverText}
</motion.span>
)}
</div>
</motion.div>
</>
);
}
+1 -68
View File
@@ -1,70 +1,3 @@
"use client";
import { useEffect, useRef } from "react";
export default function FilmGrain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
if (reducedMotion || coarsePointer) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
let lastFrame = 0;
const fps = 24; // Cinematic film fps
const frameInterval = 1000 / fps;
const resize = () => {
canvas.width = window.innerWidth / 2;
canvas.height = window.innerHeight / 2;
};
resize();
window.addEventListener("resize", resize);
const render = (timestamp: number) => {
animId = requestAnimationFrame(render);
const delta = timestamp - lastFrame;
if (delta < frameInterval) return;
lastFrame = timestamp - (delta % frameInterval);
const { width, height } = canvas;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const noise = Math.random() * 20;
data[i] = noise;
data[i + 1] = noise;
data[i + 2] = noise;
data[i + 3] = 12; // Very subtle
}
ctx.putImageData(imageData, 0, 0);
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={canvasRef}
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
style={{ imageRendering: "pixelated" }}
/>
);
return <div aria-hidden="true" className="film-grain" />;
}
@@ -1,178 +0,0 @@
"use client";
import {
Float,
MeshDistortMaterial,
MeshTransmissionMaterial,
} from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
function GlassSphere({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = Math.sin(t * 0.3) * 0.2;
meshRef.current.rotation.z = Math.cos(t * 0.2) * 0.1;
});
return (
<Float speed={1.5} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={meshRef} position={position} scale={scale}>
<sphereGeometry args={[1, 32, 32]} />
<MeshDistortMaterial
color="#ccff00"
transparent
opacity={0.08}
distort={0.4}
speed={2}
wireframe
/>
</mesh>
</Float>
);
}
function WireframeRing({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = t * 0.1;
meshRef.current.rotation.y = t * 0.15;
});
return (
<Float speed={1} rotationIntensity={0.5} floatIntensity={0.4}>
<mesh ref={meshRef} position={position} scale={scale}>
<torusGeometry args={[1, 0.02, 16, 100]} />
<meshStandardMaterial
color="#ccff00"
transparent
opacity={0.15}
emissive="#ccff00"
emissiveIntensity={0.1}
/>
</mesh>
</Float>
);
}
function DataStream({ count = 50 }: { count?: number }) {
const pointsRef = useRef<THREE.Points>(null);
const positions = useMemo(() => {
const pos = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
// Create a spiral/helix pattern
const angle = (i / count) * Math.PI * 8;
const radius = 2 + Math.sin(angle * 0.5) * 0.5;
pos[i3] = Math.cos(angle) * radius;
pos[i3 + 1] = (i / count - 0.5) * 12;
pos[i3 + 2] = Math.sin(angle) * radius;
}
return pos;
}, [count]);
useFrame((state) => {
if (!pointsRef.current) return;
const t = state.clock.elapsedTime;
pointsRef.current.rotation.y = t * 0.05;
const posAttr = pointsRef.current.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
arr[i3 + 1] += 0.02;
if (arr[i3 + 1] > 6) arr[i3 + 1] = -6;
}
posAttr.needsUpdate = true;
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color="#ccff00"
size={0.04}
transparent
opacity={0.4}
sizeAttenuation
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
interface FloatingElementsProps {
variant?: "sphere" | "ring" | "data" | "combined";
className?: string;
}
export default function FloatingElements({
variant = "combined",
className = "",
}: FloatingElementsProps) {
return (
<div className={`pointer-events-none absolute inset-0 ${className}`}>
<Canvas
camera={{ position: [0, 0, 6], fov: 50 }}
dpr={[1, 1.5]}
gl={{
antialias: false,
alpha: true,
powerPreference: "high-performance",
}}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.2} />
<pointLight position={[5, 5, 5]} intensity={0.3} color="#ccff00" />
{(variant === "sphere" || variant === "combined") && (
<>
<GlassSphere position={[3, 1, -2]} scale={0.8} />
<GlassSphere position={[-4, -1, -3]} scale={1.2} />
</>
)}
{(variant === "ring" || variant === "combined") && (
<>
<WireframeRing position={[0, 0, -2]} scale={1.5} />
<WireframeRing position={[-3, 2, -4]} scale={0.8} />
</>
)}
{(variant === "data" || variant === "combined") && (
<DataStream count={60} />
)}
</Canvas>
</div>
);
}
-11
View File
@@ -4,14 +4,6 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
type HeroSceneVariant =
| "command"
| "launch"
| "rescue"
| "signal"
| "diagnostic"
| "portrait";
function GridFloor() {
const meshRef = useRef<THREE.Mesh>(null);
@@ -104,13 +96,10 @@ function CameraRig() {
}
export default function HeroScene({
variant,
isVisible = true,
}: {
variant?: HeroSceneVariant;
isVisible?: boolean;
}) {
void variant;
return (
<div
className="absolute inset-0 z-0"
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { usePathname } from "next/navigation";
import MagneticButton from "./MagneticButton";
export default function HomeButton() {
const pathname = usePathname();
const isHome = pathname === "/";
return (
<AnimatePresence>
{!isHome && (
<motion.div
className="fixed left-6 top-6 z-[9000]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
>
<MagneticButton
as="a"
href="/"
strength={0.3}
aria-label="Voltar para o início"
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.72)] px-4 py-2.5 text-[11px] font-mono uppercase tracking-[0.1em] text-[rgba(244,242,237,0.76)] backdrop-blur-[16px] transition-all duration-300 hover:border-[rgba(204,255,0,0.5)] hover:text-[var(--slash)]"
>
{/* Glow effect */}
<span
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
boxShadow:
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
}}
/>
{/* Arrow left icon */}
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="relative z-10 -translate-x-0 transition-transform duration-300 group-hover:-translate-x-0.5"
>
<path d="M7.5 1.5 L2.5 6 L7.5 10.5" />
</svg>
<span className="relative z-10">Home</span>
{/* Animated neon dot */}
<motion.span
className="relative z-10 ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--slash)]"
animate={{ opacity: [1, 0.3, 1], scale: [1, 0.8, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
/>
</MagneticButton>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,108 +0,0 @@
"use client";
import { type ReactNode, useEffect, useRef } from "react";
interface HorizontalScrollProps {
children: ReactNode;
className?: string;
}
export default function HorizontalScroll({
children,
className = "",
}: HorizontalScrollProps) {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
const track = trackRef.current;
if (!container || !track) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Disable on mobile for better UX
if (window.innerWidth < 768) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const totalWidth = track.scrollWidth;
const viewportWidth = window.innerWidth;
const tween = gsap.to(track, {
x: -(totalWidth - viewportWidth),
ease: "none",
scrollTrigger: {
trigger: container,
pin: true,
scrub: 1,
end: () => `+=${totalWidth - viewportWidth}`,
invalidateOnRefresh: true,
},
});
// Animate individual items as they come into view
const items = track.querySelectorAll("[data-scroll-item]");
for (const item of items) {
gsap.fromTo(
item,
{
opacity: 0.3,
scale: 0.9,
rotateY: -8,
transformPerspective: 1000,
},
{
opacity: 1,
scale: 1,
rotateY: 0,
scrollTrigger: {
trigger: item,
containerAnimation: tween,
start: "left 80%",
end: "left 30%",
scrub: true,
},
},
);
}
cleanup = () => {
ScrollTrigger.getAll().forEach((st) => {
if (
st.trigger === container ||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
tween
) {
st.kill();
}
});
tween.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, []);
return (
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
<div
ref={trackRef}
className="flex items-stretch gap-sp-6 will-change-transform"
>
{children}
</div>
</div>
);
}
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
target?: string;
rel?: string;
download?: boolean | string;
"data-cursor"?: string;
type?: "button" | "submit" | "reset";
wrapperClassName?: string;
"aria-label"?: string;
@@ -29,7 +28,6 @@ export default function MagneticButton({
target,
rel,
download,
"data-cursor": dataCursor,
type,
wrapperClassName = "",
"aria-label": ariaLabel,
@@ -84,7 +82,6 @@ export default function MagneticButton({
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
{...motionProps}
{...linkProps}
{...buttonProps}
@@ -102,7 +99,6 @@ export default function MagneticButton({
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
{...motionProps}
{...linkProps}
{...buttonProps}
@@ -1,53 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import PageIntro from "./PageIntro";
jest.mock("react-dom", () => ({
...jest.requireActual("react-dom"),
createPortal: (children: ReactNode) => children,
}));
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get:
(_target, tag: string) =>
({ children, ...props }: { children?: ReactNode }) =>
React.createElement(tag, props, children),
},
);
return {
AnimatePresence: ({ children }: { children: ReactNode }) => children,
motion,
useReducedMotion: () => false,
};
});
describe("PageIntro", () => {
it("renders the compact signal loading state without a fake percentage", () => {
render(<PageIntro />);
expect(screen.getByText(/EVANDRO.*DEV/)).toBeInTheDocument();
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
expect(screen.queryByText("000")).not.toBeInTheDocument();
});
it("stays visible long enough to be perceived on a fast page load", () => {
jest.useFakeTimers();
render(<PageIntro />);
act(() => jest.advanceTimersByTime(900));
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
act(() => jest.runAllTimers());
expect(
screen.queryByText("Preparando o portfólio"),
).not.toBeInTheDocument();
jest.useRealTimers();
});
});
-107
View File
@@ -1,107 +0,0 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
const EXIT_EASE: [number, number, number, number] = [0.76, 0, 0.24, 1];
export default function PageIntro() {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(true);
const reduceMotion = useReducedMotion();
useEffect(() => {
setMounted(true);
const timeout = window.setTimeout(
() => setVisible(false),
reduceMotion ? 150 : 1350,
);
return () => window.clearTimeout(timeout);
}, [reduceMotion]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{visible && (
<motion.div
className="fixed inset-0 z-[99999] grid overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
initial={{ opacity: 1 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
clipPath: "inset(0 0 100% 0)",
transition: { duration: 0.65, ease: EXIT_EASE },
}
}
aria-live="polite"
aria-label="Preparando o portfólio"
>
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
<div className="relative m-auto w-[min(88vw,680px)]">
<motion.p
className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
>
SINAL DE ENTRADA
</motion.p>
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
<motion.p
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.48,
delay: 0.06,
ease: [0.16, 1, 0.3, 1],
}}
>
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
</motion.p>
<motion.div
className="mt-sp-8 flex items-center gap-sp-4"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, delay: 0.18 }}
>
<span className="relative block h-2 w-2" aria-hidden="true">
<span className="absolute inset-0 bg-[var(--slash)]" />
{!reduceMotion && (
<motion.span
className="absolute inset-0 bg-[var(--slash)]"
animate={{ scale: [1, 2.8], opacity: [0.8, 0] }}
transition={{
duration: 0.8,
repeat: Number.POSITIVE_INFINITY,
}}
/>
)}
</span>
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
Preparando o portfólio
</p>
<span
className="h-px flex-1 bg-[rgba(244,242,237,.22)]"
aria-hidden="true"
/>
</motion.div>
</div>
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
<span>Engenharia de software</span>
<span>Garuva · Joinville · Curitiba</span>
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}
+55 -76
View File
@@ -1,100 +1,79 @@
"use client";
import {
motion,
useAnimationFrame,
useMotionValue,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useState } from "react";
interface ScrollVelocityTextProps {
text: string;
baseVelocity?: number;
direction: "left" | "right";
className?: string;
}
function wrap(min: number, max: number, v: number) {
const rangeSize = max - min;
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
}
const GROUPS = [0, 1] as const;
export default function ScrollVelocityText({
text,
baseVelocity = 2,
direction,
className = "",
}: ScrollVelocityTextProps) {
const baseX = useMotionValue(0);
const { scrollY } = useScroll();
const scrollVelocity = useMotionValue(0);
const smoothVelocity = useSpring(scrollVelocity, {
damping: 50,
stiffness: 300,
});
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
clamp: false,
});
const [repetitions, setRepetitions] = useState(4);
const containerRef = useRef<HTMLDivElement>(null);
const trackId = useId();
const [isPaused, setIsPaused] = useState(false);
const [shouldReduceMotion, setShouldReduceMotion] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const handleResize = () => {
setRepetitions(Math.ceil(window.innerWidth / 400) + 2);
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const updatePreference = (event: MediaQueryListEvent) => {
setShouldReduceMotion(event.matches);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
media.addEventListener("change", updatePreference);
return () => media.removeEventListener("change", updatePreference);
}, []);
// Track scroll velocity
const prevScrollY = useRef(0);
useEffect(() => {
return scrollY.on("change", (latest) => {
const delta = latest - prevScrollY.current;
prevScrollY.current = latest;
scrollVelocity.set(Math.abs(delta));
});
}, [scrollY, scrollVelocity]);
const directionFactor = useRef(1);
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
useAnimationFrame((_, delta) => {
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
const vf = velocityFactor.get();
if (vf < 0) {
directionFactor.current = -1;
} else if (vf > 0) {
directionFactor.current = 1;
}
moveBy += directionFactor.current * moveBy * vf;
baseX.set(baseX.get() + moveBy);
});
const animationPaused = shouldReduceMotion || isPaused;
return (
<div
ref={containerRef}
className={`overflow-hidden whitespace-nowrap ${className}`}
>
<motion.div className="inline-flex" style={{ x }}>
{[...Array(repetitions)].map((_, i) => (
<span
key={i}
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
>
{text}
<span className="mx-8 inline-block text-[var(--slash)]">
&#x2022;
<div className={`scroll-velocity-text ${className}`.trim()}>
<div className="scroll-velocity-viewport">
<div
id={trackId}
className={`scroll-velocity-track ${
direction === "left" ? "animate-marquee" : "animate-marquee-reverse"
}`}
style={{
animationPlayState: animationPaused ? "paused" : undefined,
}}
>
{GROUPS.map((group) => (
<span
key={group}
className="scroll-velocity-group"
aria-hidden={group === 0 ? undefined : "true"}
>
<span className="scroll-velocity-item">
<span aria-hidden={group === 0 ? "false" : "true"}>{text}</span>
<span aria-hidden="true" className="scroll-velocity-dot">
&#x2022;
</span>
</span>
</span>
</span>
))}
</motion.div>
))}
</div>
</div>
{!shouldReduceMotion ? (
<button
type="button"
className="scroll-velocity-control"
aria-controls={trackId}
aria-pressed={isPaused}
onClick={() => setIsPaused((paused) => !paused)}
>
{isPaused ? "Retomar animação" : "Pausar animação"}
</button>
) : null}
</div>
);
}
+17 -87
View File
@@ -1,114 +1,44 @@
"use client";
import { useEffect, useRef } from "react";
interface SectionDividerProps {
variant?: "gradient" | "pulse" | "waveform";
className?: string;
}
const PULSE_DOTS = ["dot-1", "dot-2", "dot-3", "dot-4", "dot-5"] as const;
export default function SectionDivider({
variant = "gradient",
className = "",
}: SectionDividerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (variant !== "waveform") return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = 60;
};
resize();
window.addEventListener("resize", resize);
const render = () => {
animId = requestAnimationFrame(render);
const { width, height } = canvas;
const t = performance.now() * 0.001;
ctx.clearRect(0, 0, width, height);
// Draw multiple waveforms
for (let wave = 0; wave < 3; wave++) {
ctx.beginPath();
ctx.strokeStyle = `rgba(204, 255, 0, ${0.1 - wave * 0.03})`;
ctx.lineWidth = 1;
for (let x = 0; x < width; x++) {
const frequency = 0.005 + wave * 0.002;
const amplitude = 8 + wave * 4;
const speed = t * (0.5 + wave * 0.2);
const y =
height / 2 +
Math.sin(x * frequency + speed) * amplitude +
Math.sin(x * frequency * 2.3 + speed * 1.5) * (amplitude * 0.4);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, [variant]);
const rootClassName =
`section-divider section-divider-${variant} ${className}`.trim();
if (variant === "gradient") {
return (
<div className={`relative h-px w-full overflow-hidden ${className}`}>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-30" />
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-60"
style={{
animation: "shimmerDivider 3s ease-in-out infinite",
}}
/>
<div aria-hidden="true" className={rootClassName}>
<span />
<span />
</div>
);
}
if (variant === "pulse") {
return (
<div
className={`flex items-center justify-center gap-2 py-8 ${className}`}
>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-1 w-1 rounded-full bg-[var(--slash)]"
style={{
animation: `pulseDot 2s ease-in-out ${i * 0.15}s infinite`,
}}
/>
<div aria-hidden="true" className={rootClassName}>
{PULSE_DOTS.map((dot) => (
<span key={dot} />
))}
</div>
);
}
// waveform
return (
<div className={`relative ${className}`}>
<canvas
ref={canvasRef}
className="w-full opacity-60"
style={{ height: 60 }}
aria-hidden="true"
/>
<div aria-hidden="true" className={rootClassName}>
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
<title>Divisor decorativo</title>
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
</svg>
</div>
);
}