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

+55 -5
View File
@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { portfolio } from "@/lib/portfolio-data";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(portfolio.projects);
const projects = selectNotebookProjects(getFeaturedProjects(null));
const frame: NotebookBrowserProps["frame"] = {
cursorVisible: true,
cursorX: 0.4,
@@ -113,15 +113,65 @@ describe("NotebookBrowser", () => {
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("exposes a real deployment link with safe new-tab attributes", () => {
renderBrowser();
it("keeps an unknown demo link and announces its state", () => {
const unknownProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unknown" as const,
demoCheckedAt: null,
}
: project,
);
renderBrowser({ projects: unknownProjects });
const link = screen.getByRole("link", { name: /abrir projeto/i });
const link = screen.getByRole("link", {
name: /abrir projeto.*disponibilidade não verificada/i,
});
expect(link).toHaveAttribute("href", projects[0].demoUrl);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("renders an unavailable demo as non-navigable text", () => {
const unavailableProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
renderBrowser({ projects: unavailableProjects });
const unavailable = screen.getByText(
"Demo indisponível na última verificação",
);
expect(unavailable).toHaveAttribute("aria-disabled", "true");
expect(unavailable.closest("a")).toBeNull();
expect(
screen.queryByRole("link", { name: /abrir projeto/i }),
).not.toBeInTheDocument();
});
it("renders no demo action for a project without a demo", () => {
const noDemoProjects = [
{
...projects[0],
demoUrl: null,
demoAvailability: "none",
demoCheckedAt: null,
},
] as unknown as NotebookBrowserProps["projects"];
renderBrowser({ projects: noDemoProjects });
expect(screen.queryByText(/abrir projeto/i)).not.toBeInTheDocument();
expect(
screen.queryByText("Demo indisponível na última verificação"),
).not.toBeInTheDocument();
});
it("keeps the animated cursor decorative and never embeds an iframe", () => {
const { container } = renderBrowser({
frame: { ...frame, cursorClicking: true },
+20 -7
View File
@@ -176,13 +176,26 @@ export default function NotebookBrowser({
<li key={technology}>{technology}</li>
))}
</ul>
<a
href={selectedProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto <span aria-hidden="true">↗</span>
</a>
{selectedProject.demoAvailability === "unavailable" ? (
<span aria-disabled="true">
Demo indisponível na última verificação
</span>
) : selectedProject.demoAvailability === "none" ||
!selectedProject.demoUrl ? null : (
<a
href={selectedProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto <span aria-hidden="true">↗</span>
{selectedProject.demoAvailability === "unknown" ? (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
) : null}
</a>
)}
</div>
<div className="notebook-screenshot-wrap">
@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { portfolio } from "@/lib/portfolio-data";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(portfolio.projects);
const projects = selectNotebookProjects(getFeaturedProjects(null));
describe("NotebookFallback", () => {
it("keeps project content and controls in normal document flow", () => {
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import * as THREE from "three";
import { portfolio } from "@/lib/portfolio-data";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStage3D, {
getNotebookLidRotation,
type NotebookStage3DProps,
@@ -30,7 +30,7 @@ jest.mock("@react-three/drei", () => ({
RoundedBox: () => <div data-testid="mock-roundedbox" />,
}));
const projects = selectNotebookProjects(portfolio.projects);
const projects = selectNotebookProjects(getFeaturedProjects(null));
const props: NotebookStage3DProps = {
activeIndex: 0,
isVisible: true,
+82 -5
View File
@@ -1,14 +1,18 @@
import { render, screen, waitFor } from "@testing-library/react";
import { portfolio } from "@/lib/portfolio-data";
import { act, render, screen, waitFor } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, {
canUseNotebook3D,
getNotebookRunwayVh,
getNotebookSeekProgress,
} from "./NotebookStory";
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
));
jest.mock("next/dynamic", () => () => {
return function DynamicNotebookStage() {
return <div data-testid="notebook-stage" />;
return function DynamicNotebookStage(props: { isVisible: boolean }) {
return mockNotebookStage(props);
};
});
@@ -26,7 +30,23 @@ jest.mock("gsap", () => ({
jest.mock("gsap/ScrollTrigger", () => ({
ScrollTrigger: { getAll: jest.fn(() => []) },
}));
const projects = portfolio.projects;
const projects = getFeaturedProjects(null);
let intersectionCallback: IntersectionObserverCallback | undefined;
const mockObserve = jest.fn();
const mockDisconnect = jest.fn();
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setSectionIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function mockMatchMedia({
reduced = false,
@@ -62,6 +82,26 @@ describe("NotebookStory", () => {
configurable: true,
value: 1280,
});
setDocumentHidden(false);
intersectionCallback = undefined;
mockObserve.mockReset();
mockDisconnect.mockReset();
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = mockDisconnect;
observe = mockObserve;
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
@@ -99,6 +139,43 @@ describe("NotebookStory", () => {
screen.getByLabelText("Projetos em produção no notebook"),
).toHaveAttribute("data-notebook-runway-vh", "520");
});
it("stays paused when the tab returns while the section is offscreen", async () => {
const addEventListener = jest.spyOn(document, "addEventListener");
const removeEventListener = jest.spyOn(document, "removeEventListener");
const { unmount } = render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage");
expect(stage).toHaveAttribute("data-is-visible", "false");
setSectionIntersects(true);
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "true"),
);
setDocumentHidden(true);
act(() => document.dispatchEvent(new Event("visibilitychange")));
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "false"),
);
setSectionIntersects(false);
setDocumentHidden(false);
act(() => document.dispatchEvent(new Event("visibilitychange")));
expect(stage).toHaveAttribute("data-is-visible", "false");
expect(
addEventListener.mock.calls.filter(
([type]) => type === "visibilitychange",
),
).toHaveLength(1);
unmount();
expect(
removeEventListener.mock.calls.filter(
([type]) => type === "visibilitychange",
),
).toHaveLength(1);
});
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
+21 -6
View File
@@ -3,7 +3,7 @@
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProject } from "@/lib/portfolio-data";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
import { getNotebookFrame } from "./notebookTimeline";
@@ -18,7 +18,7 @@ const BROWSE_END = 0.78;
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProject[];
projects: PortfolioProjectView[];
};
export function canUseNotebook3D(view: CapabilityWindow): boolean {
@@ -64,11 +64,17 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
const progressRef = useRef(0);
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
const [engine, setEngine] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const [sectionIntersects, setSectionIntersects] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() => typeof document === "undefined" || !document.hidden,
);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => {
const updateEngine = () => {
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
const nextEngine =
notebookProjects.length > 0 && canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine);
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
@@ -91,6 +97,15 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
};
}, [notebookProjects.length]);
useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
useEffect(() => {
const section = sectionRef.current;
if (!engine || !section || !("IntersectionObserver" in window)) {
@@ -98,7 +113,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
}
const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry?.isIntersecting ?? false),
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
{ threshold: 0 },
);
observer.observe(section);
@@ -246,7 +261,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
</div>
<NotebookStage3D
activeIndex={activeIndex}
isVisible={isVisible}
isVisible={sectionIntersects && isPageVisible}
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
-149
View File
@@ -1,149 +0,0 @@
"use client";
import { useEffect } from "react";
/**
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
*
* Contrato (declarativo, via atributos data-*):
* [data-reel="left|right|top|bottom|zoom|fade"]
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
* [data-reel-stagger="left|right|top|zoom|fade|bottom"]
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
* data-reel-distance="64" (opcional: px de deslocamento)
*
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
*
* Anti-hidratação: elementos inicializados vivem num WeakSet (nunca um
* atributo data-reel-init no DOM) e o primeiro scan roda depois que a
* hidratação assentou (double-rAF + debounce) — MutationObserver dispara em
* microtask e pegaria nós ainda em hidratação, produzindo mismatch de
* atributos no overlay do Next.
*/
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
const DISTANCE = 64;
function fromVars(dir: Direction, dist: number) {
const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
if (dir === "left") return { ...base, x: -dist };
if (dir === "right") return { ...base, x: dist };
if (dir === "top") return { ...base, y: -dist * 0.75 };
if (dir === "bottom") return { ...base, y: dist };
if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
return { ...base, scale: 1.02 };
}
const TO_VARS = {
opacity: 1,
x: 0,
y: 0,
scale: 1,
filter: "blur(0px)",
ease: "none",
};
export default function ReelDirector() {
useEffect(() => {
let cancelled = false;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return undefined;
let teardown = () => {};
let raf1 = 0;
let raf2 = 0;
let timer = 0;
const boot = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const done = new WeakSet<Element>();
const ctx = gsap.context(() => {
const scan = () => {
if (cancelled) return;
for (const el of document.querySelectorAll<HTMLElement>(
"[data-reel]",
)) {
if (done.has(el)) continue;
done.add(el);
const dir = (el.dataset.reel || "bottom") as Direction;
const dist = Number(el.dataset.reelDistance || DISTANCE);
gsap.fromTo(el, fromVars(dir, dist), {
...TO_VARS,
scrollTrigger: {
trigger: el,
start: el.dataset.reelStart || "top 88%",
end: el.dataset.reelEnd || "top 26%",
scrub: 0.6,
},
});
}
for (const container of document.querySelectorAll<HTMLElement>(
"[data-reel-stagger]",
)) {
if (done.has(container)) continue;
done.add(container);
const dir = (container.dataset.reelStagger ||
"bottom") as Direction;
const dist = Number(container.dataset.reelDistance || DISTANCE);
const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) continue;
gsap.fromTo(children, fromVars(dir, dist), {
...TO_VARS,
stagger: { each: 0.09 },
scrollTrigger: {
trigger: container,
start: container.dataset.reelStart || "top 85%",
end: container.dataset.reelEnd || "top 25%",
scrub: 0.6,
},
});
}
};
// debounce: nós de seções dinâmicas (next/dynamic) chegam depois
let pending = 0;
const scheduleScan = () => {
window.clearTimeout(pending);
pending = window.setTimeout(scan, 120);
};
// espera a hidratação assentar antes de tocar o DOM
raf1 = window.requestAnimationFrame(() => {
raf2 = window.requestAnimationFrame(() => {
timer = window.setTimeout(scan, 250);
});
});
const observer = new MutationObserver(scheduleScan);
observer.observe(document.body, { childList: true, subtree: true });
teardown = () => {
window.clearTimeout(pending);
observer.disconnect();
ctx.revert();
};
});
};
void boot();
return () => {
cancelled = true;
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
window.clearTimeout(timer);
teardown();
};
}, []);
return null;
}
+48 -17
View File
@@ -1,7 +1,10 @@
import { type PortfolioProject, portfolio } from "@/lib/portfolio-data";
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import { selectNotebookProjects } from "./notebookProjects";
const projectsFixture = portfolio.projects as PortfolioProject[];
const projectsFixture = getFeaturedProjects(null);
describe("selectNotebookProjects", () => {
it("returns the four ordered projects with local screenshots", () => {
@@ -31,26 +34,54 @@ describe("selectNotebookProjects", () => {
).toBe(false);
});
it("omits a project without an approved demo URL", () => {
const projectsWithoutDemoUrl = projectsFixture.map((project) =>
project.id === "nutriscan" ? { ...project, demoUrl: null } : project,
) as unknown as PortfolioProject[];
const result = selectNotebookProjects(projectsWithoutDemoUrl);
it("omits a project with an unapproved demo URL", () => {
const projectsWithUnsafeUrl = projectsFixture.map((project) =>
project.id === "nutriscan"
? { ...project, demoUrl: "javascript:alert(1)" }
: project,
);
const result = selectNotebookProjects(projectsWithUnsafeUrl);
expect(result.some((project) => project.id === "nutriscan")).toBe(false);
});
it("copies only the browser contract fields", () => {
const [project] = selectNotebookProjects(projectsFixture);
it("keeps a project without a demo so its local evidence remains visible", () => {
const projectsWithoutDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoUrl: null,
demoAvailability: "none" as const,
demoCheckedAt: null,
}
: project,
) as unknown as PortfolioProjectView[];
expect(project).toEqual({
id: "nutriscan",
title: "NutriScan",
subtitle: "Análise Nutricional Inteligente",
demoUrl: "https://nutriscan.evandro.dev.br",
technologies: expect.arrayContaining(["Next.js 14", "TypeScript"]),
screenshotSrc: "/assets/prints/print-nutriscan.png",
expect(
selectNotebookProjects(projectsWithoutDemo).some(
(project) => project.id === "nutriscan",
),
).toBe(true);
});
it("keeps an unavailable project when its local evidence and URL exist", () => {
const projectsWithUnavailableDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
expect(
selectNotebookProjects(projectsWithUnavailableDemo).find(
(project) => project.id === "nutriscan",
),
).toMatchObject({
demoAvailability: "unavailable",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
});
expect(project).not.toHaveProperty("description");
});
});
+12 -5
View File
@@ -1,10 +1,12 @@
import type { PortfolioProject } from "@/lib/portfolio-data";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
export type NotebookProject = {
id: string;
title: string;
subtitle: string;
demoUrl: string;
demoUrl: PortfolioProjectView["demoUrl"];
demoAvailability: PortfolioProjectView["demoAvailability"];
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
technologies: string[];
screenshotSrc: string;
};
@@ -27,19 +29,22 @@ const SCREENSHOT_BY_PROJECT_ID: Record<
};
function isApprovedDemoUrl(
value: PortfolioProject["demoUrl"],
value: PortfolioProjectView["demoUrl"],
): value is string {
return typeof value === "string" && /^https?:\/\//.test(value);
}
export function selectNotebookProjects(
projects: readonly PortfolioProject[],
projects: readonly PortfolioProjectView[],
): NotebookProject[] {
return NOTEBOOK_ORDER.flatMap((id) => {
const project = projects.find((candidate) => candidate.id === id);
const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id];
if (!project || !isApprovedDemoUrl(project.demoUrl)) {
if (
!project ||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
) {
return [];
}
@@ -49,6 +54,8 @@ export function selectNotebookProjects(
title: project.title,
subtitle: project.subtitle,
demoUrl: project.demoUrl,
demoAvailability: project.demoAvailability,
demoCheckedAt: project.demoCheckedAt,
technologies: [...project.technologies],
screenshotSrc,
},