Merge branch 'design/espaco-e-consistencia'

Hero com cena WebGL legivel (tela, integracoes, dados) e mergulho com legendas,
rolagem suave e transicoes de secao ligadas a rolagem (desktop e celular),
transicao entre paginas, remocao da secao do notebook, otimizacoes de
desempenho, SEO/AEO/GEO e prompts de logo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Opus 5.5 committed 2026-10-05 01:28:01 -03:00
commit 6db10ffa67
62 files changed
+2593 -4537

No files matched your search

+4 -2
View File
@@ -209,7 +209,7 @@ it("renders the authorized detailed case in order with traceable sources", async
});
it("keeps the fallback concise and links its declared services", async () => {
const caseStudy = getCase("gcc-installer");
const caseStudy = getCase("sc-plus");
expect(caseStudy.detail).toBeUndefined();
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
@@ -222,7 +222,9 @@ it("keeps the fallback concise and links its declared services", async () => {
for (const technology of caseStudy.stack) {
expect(screen.getByText(technology)).toBeVisible();
}
expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull();
expect(
screen.getByRole("link", { name: "Abrir demonstração" }),
).toHaveAttribute("href", caseStudy.demoUrl);
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
"href",
caseStudy.githubUrl,
+10 -1
View File
@@ -35,10 +35,11 @@ export async function generateMetadata({
const url = `${siteUrl}/cases/${caseStudy.id}`;
return {
title: `${caseStudy.title} | Case de Engenharia de Software`,
title: `${caseStudy.title} | Case de Engenharia`,
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
locale: "pt_BR",
title: caseStudy.title,
description: caseStudy.summary,
url,
@@ -109,6 +110,14 @@ export default async function CasePage({ params }: CasePageProps) {
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.detail?.answer.text ?? caseStudy.summary}
</p>
{caseStudy.role ? (
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
<strong className="font-semibold text-white">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
{!caseStudy.detail ? (
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
+1
View File
@@ -18,6 +18,7 @@ export const metadata: Metadata = {
canonical: pageUrl,
},
openGraph: {
locale: "pt_BR",
title: pageTitle,
description: pageDescription,
url: pageUrl,
+4 -3
View File
@@ -2,12 +2,13 @@ import { render, screen } from "@testing-library/react";
import Footer from "./Footer";
describe("Footer contact and navigation", () => {
it("offers project contact and a direct mailto link", () => {
it("offers a direct mailto link without repeating the contact call to action", () => {
render(<Footer />);
// o convite principal fica na secao Contato, logo acima do rodape
expect(
screen.getByRole("link", { name: "Conversar sobre meu projeto" }),
).toHaveAttribute("href", "/#contact");
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
).not.toBeInTheDocument();
const emailLink = screen.getByRole("link", {
name: "[email protected]",
+2 -19
View File
@@ -52,31 +52,14 @@ export default function Footer() {
<div>
<div className="ds-kicker">Contato</div>
<span className="ds-draw-line" />
<h3 className="ds-card-title">
Vamos conversar sobre o software que seu negócio precisa?
</h3>
<p className="ds-copy max-w-[48ch]">
Conte o que você quer construir, integrar ou melhorar. Podemos
discutir o contexto, as prioridades e o escopo antes de definir
os próximos passos.
</p>
<MagneticButton
as="a"
href="/#contact"
className="ds-link ds-link-accent mt-sp-6 inline-flex min-h-11 items-center rounded-sm px-sp-4 font-semibold"
strength={0.2}
>
Conversar sobre meu projeto
</MagneticButton>
<div className="mt-sp-6">
<div className="mt-sp-5">
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
E-mail direto
</p>
<MagneticButton
as="a"
href="mailto:[email protected]"
className="inline-block break-all text-[16px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)] sm:text-[18px]"
className="inline-flex min-h-11 items-center break-all text-[16px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)] sm:text-[18px]"
strength={0.2}
>
evandrofonsecajunior@gmail.com
+4 -1
View File
@@ -246,7 +246,10 @@ function GoogleAnalyticsConsent() {
className={`${buttonClassName} fixed bottom-sp-4 right-sp-4 z-[9980] border-white/25 bg-[var(--ink)] text-white shadow-[4px_4px_0_var(--slash)]`}
onClick={() => setPreferencesOpen((open) => !open)}
>
Preferências de analytics
<span className="max-sm:hidden">Preferências de analytics</span>
<span aria-hidden="true" className="sm:hidden">
Cookies
</span>
</button>
{preferencesOpen && (
<section
+15 -2
View File
@@ -32,7 +32,13 @@ describe("Header", () => {
within(navigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual(["/#modalities", "/cases", "/#process", "/#about"]);
).toEqual([
"/#modalities",
"/cases",
"/#process",
"/#about",
"/#experience",
]);
const contactLink = screen
.getAllByRole("link")
.find((link) => link.getAttribute("href") === "/#contact");
@@ -68,7 +74,14 @@ describe("Header", () => {
within(mobileNavigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual(["/#modalities", "/cases", "/#process", "/#about", "/#contact"]);
).toEqual([
"/#modalities",
"/cases",
"/#process",
"/#about",
"/#experience",
"/#contact",
]);
expect(
within(mobileNavigation).queryByRole("link", {
name: /currículo|download cv/i,
+1 -1
View File
@@ -38,7 +38,7 @@ export function Header() {
return (
<header
className={`fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
scrolled
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
: "border-transparent bg-[var(--ink)]"
+7 -3
View File
@@ -50,9 +50,13 @@ describe("Hero", () => {
"href",
"/cases",
);
expect(screen.getByRole("link", { name: /nutriscan/i })).toHaveAttribute(
"href",
"/cases/nutriscan",
});
it("keeps the headline as one readable sentence despite the per-word animation", () => {
render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sistemas sob medida para operações que não cabem em soluções prontas.",
);
});
+139 -62
View File
@@ -1,9 +1,6 @@
"use client";
import { motion } from "motion/react";
import Link from "next/link";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import HeroSystem from "./cinematic/HeroSystem";
import { type CSSProperties, Fragment } from "react";
import HeroField from "./cinematic/HeroField";
const SOCIAL_LINKS = [
{
@@ -20,88 +17,168 @@ const SOCIAL_LINKS = [
},
];
export default function Hero() {
const shouldReduceMotion = useReducedMotion();
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
export type HeroStats = {
years?: number;
projects?: number;
demos?: number;
};
function statItems(stats?: HeroStats) {
if (!stats) return [];
return [
stats.years ? [String(stats.years), "anos de experiência"] : null,
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
].filter((item): item is [string, string] => item !== null);
}
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" ");
const ACCENT = new Set([1, 2]);
/** O que cada camada da cena significa, em linguagem simples. */
const CAPTIONS = [
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
["As integrações", "Os programas que você já usa conversando entre si, sem retrabalho."],
["Os dados", "Suas informações guardadas, organizadas e seguras."],
] as const;
export default function Hero({ stats }: { stats?: HeroStats }) {
const items = statItems(stats);
return (
<section
id="home"
className="w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
>
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.45,
ease: "easeOut",
}}
className="mx-auto grid w-full max-w-[1400px] grid-cols-1 items-center gap-sp-8 px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-10 lg:grid-cols-[minmax(0,53fr)_minmax(0,47fr)] lg:gap-sp-8 lg:px-sp-12 lg:pb-sp-16"
>
<div className="min-w-0">
<p className="mb-sp-5 text-[14px] leading-[1.5] text-[rgba(244,242,237,.72)]">
<div className="hero-stage">
<div className="hero-glow" aria-hidden="true" />
<HeroField />
<div className="hero-scrim" aria-hidden="true" />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{CAPTIONS.map(([title, text], index) => (
<li key={title} style={{ "--i": index } as CSSProperties}>
<span className="hero-caption-title">{title}</span>
{text}
</li>
))}
</ol>
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
</p>
<h1 className="max-w-[700px] text-balance text-[34px] font-semibold leading-[1.05] tracking-[-0.045em] sm:text-[48px] xl:text-[60px]">
Sistemas sob medida para operações que não cabem em soluções
prontas.
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
{TITLE.map((word, index) => (
<Fragment key={word}>
<span
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>{" "}
</Fragment>
))}
</h1>
<p className="mt-sp-5 max-w-[600px] text-[16px] leading-[1.55] text-[rgba(244,242,237,.8)] lg:text-[18px]">
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
entendimento do negócio à arquitetura e à entrega em produção.
</p>
<div className="mt-sp-6 flex flex-col gap-sp-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
<Link
href="/#contact"
className="inline-flex min-h-12 items-center justify-center bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
>
Conversar sobre meu projeto
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</Link>
<Link
href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/12 px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
>
Ver projetos entregues
</Link>
</div>
<p className="mt-sp-3 text-[13px] leading-[1.5] text-[rgba(244,242,237,.64)]">
Santa Catarina · Atendimento remoto
</div>
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{items.length > 0 && (
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
{items.map(([value, label]) => (
<div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label}
</dt>
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{value}
</dd>
</div>
))}
</dl>
)}
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
<div className="max-lg:hidden">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
<li key={service} className="flex items-center gap-sp-2">
<span
aria-hidden="true"
className="h-1 w-1 bg-[var(--slash)]"
/>
{service}
</li>
),
)}
</ul>
<div className="flex flex-wrap items-center gap-x-sp-5">
{SOCIAL_LINKS.map((link) => (
<a
key={link.label}
href={link.href}
target={
link.href.startsWith("http") ? "_blank" : undefined
}
rel={
link.href.startsWith("http")
? "noopener noreferrer"
: undefined
}
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
>
{link.label}
</a>
))}
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
Santa Catarina · Atendimento remoto
</span>
</div>
</div>
</div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
>
Role para mergulhar
<span className="hero-cue-line" />
</p>
</div>
<div className="min-w-0 lg:col-start-2 lg:row-start-1 lg:row-span-2">
<HeroSystem />
</div>
<div className="min-w-0 lg:col-start-1 lg:row-start-2">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sistemas web", "Aplicativos", "Integrações"].map((service) => (
<li key={service} className="flex items-center gap-sp-2">
<span
aria-hidden="true"
className="h-1 w-1 bg-[var(--slash)]"
/>
{service}
</li>
))}
</ul>
<div className="mt-sp-2 flex flex-wrap gap-x-sp-5">
{SOCIAL_LINKS.map((link) => (
<a
key={link.label}
href={link.href}
target={link.href.startsWith("http") ? "_blank" : undefined}
rel={
link.href.startsWith("http")
? "noopener noreferrer"
: undefined
}
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
>
{link.label}
</a>
))}
</div>
</div>
</motion.div>
</div>
</section>
);
}
+56
View File
@@ -0,0 +1,56 @@
import Image from "next/image";
type CoverProject = { id: string; title: string };
/** Capturas de tela publicadas dos projetos que têm interface pública. */
const SCREENSHOTS: Record<string, string | undefined> = {
nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/notebook/as-servicos.webp",
};
function initials(title: string) {
return title
.split(/\s+/)
.filter((word) => word.length > 2 || /^[A-Z]/.test(word))
.slice(0, 2)
.map((word) => word[0])
.join("")
.toUpperCase();
}
/**
* Capa do card de projeto: a captura de tela real quando existe; sem captura, um painel gerado em código
* (só o monograma, sem repetir a stack que já aparece nas tags do card) em vez de uma imagem inventada.
*/
export function ProjectCover({
project,
priority = false,
}: {
project: CoverProject;
priority?: boolean;
}) {
const src = SCREENSHOTS[project.id];
if (src) {
return (
<div className="ds-cover">
<Image
src={src}
alt={`Captura de tela de ${project.title}`}
width={1600}
height={1000}
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
priority={priority}
/>
</div>
);
}
return (
<div className="ds-cover ds-cover-fallback" aria-hidden="true">
<span className="ds-cover-mark">{initials(project.title)}</span>
</div>
);
}
+9 -1
View File
@@ -6,6 +6,7 @@ import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { caseStudies } from "@/lib/site-content";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { ProjectCover } from "./ProjectCover";
import { Section } from "./Section";
export default function Projects({
@@ -35,7 +36,7 @@ export default function Projects({
dark
disableChildrenAnimation
>
<div className="ds-grid-2">
<div className="ds-grid-fluid">
{projects.map((project) => {
const caseStudy = caseStudies.find(
(entry) => entry.id === project.id,
@@ -59,6 +60,7 @@ export default function Projects({
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<ProjectCover project={project} />
<div className="flex flex-wrap items-start justify-between gap-sp-6">
<div className="max-w-[560px]">
<div className="ds-kicker">{project.category}</div>
@@ -89,6 +91,12 @@ export default function Projects({
<p className="ds-copy">{caseStudy.impact}</p>
</div>
)}
{caseStudy?.role && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">Minha participação</p>
<p className="ds-copy">{caseStudy.role}</p>
</div>
)}
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
+3 -3
View File
@@ -165,7 +165,7 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
{tier.pricing}
</div>
<div className="text-[12px] leading-[1.7] text-[var(--meta)]">
<div className="font-display text-[12px] leading-[1.7] text-[var(--meta)]">
{tier.pricingNote}
</div>
</div>
@@ -253,10 +253,10 @@ export function WorkModalities() {
href={service.href}
className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
>
<div className="text-base font-semibold leading-snug text-[var(--ink)]">
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
{service.label}
</div>
<div className="mt-sp-2 text-sm leading-6 text-[var(--ink-80)]">
<div className="mt-sp-2 font-display text-sm leading-6 text-[var(--ink-80)]">
{service.description}
</div>
</Link>
@@ -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<string, unknown> }> = [];
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<string, unknown>;
transition?: unknown;
onAnimationComplete?: () => void;
} & Record<string, unknown>) => {
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(<TextDiffuseReveal text="Reveal" />);
mockAnimations = [];
mockReducedMotion = true;
rerender(<TextDiffuseReveal text="Reveal" />);
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(<TextDiffuseReveal text="Reveal" />);
expect(mockAnimations[0]).toEqual({
animate: {
filter: "blur(12px)",
opacity: 0,
scale: 0.9,
},
});
});
});
@@ -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(<TextDiffuseReveal text="Software feito a partir do seu negócio" />);
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(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).not.toHaveAttribute("data-shown");
mockInView = true;
rerender(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).toHaveAttribute("data-shown");
});
});
+25 -102
View File
@@ -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<TextDiffuseRevealProps>) {
const ref = useRef<HTMLSpanElement | null>(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 (
<span
ref={ref}
className={className}
style={{
willChange: hasActiveWillChange
? "filter, opacity, transform"
: undefined,
}}
className={`text-reveal ${className}`.trim()}
data-shown={isInView ? "" : undefined}
style={{ "--d": `${startDelay}s` } as CSSProperties}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{tokens.map(({ key, value: token }) => {
if (token === "\n") {
return <br key={key} />;
}
if (/^\s+$/.test(token)) {
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
}
return (
<span key={key} className="inline-block whitespace-nowrap">
{Array.from(token).map((char) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`${key}-${currentIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { filter: "blur(0px)", opacity: 0, scale: 1 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
})}
{text
.split(/(\n|\s+)/)
.filter(Boolean)
.map((token, index) => {
const key = `${index}:${token}`;
if (token === "\n") return <br key={key} />;
if (/^\s+$/.test(token)) return " ";
return (
<span key={key} className="text-reveal-word" style={{ "--i": word++ } as CSSProperties}>
{token}
</span>
);
})}
</span>
</span>
);
@@ -1,103 +1,13 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { render } 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", () => {
it("renders grain 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);
});
});
+12 -12
View File
@@ -9,7 +9,7 @@ function HomeNavigation() {
return (
<>
<button type="button" onClick={onLayoutReady}>
Establish notebook layout
Establish layout
</button>
<a href="/#about">Sobre</a>
<a href="/#contact">Contato</a>
@@ -42,7 +42,7 @@ afterEach(() => {
});
describe("home fragment navigation", () => {
it("starts an unfragmented visit at the top after the notebook establishes layout", () => {
it("starts an unfragmented visit at the top after the home establishes layout", () => {
let restoredPosition = 3200;
jest
.spyOn(window, "scrollTo")
@@ -52,7 +52,7 @@ describe("home fragment navigation", () => {
});
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(restoredPosition).toBe(0);
});
@@ -63,7 +63,7 @@ describe("home fragment navigation", () => {
.mockImplementation(() => {});
const firstVisit = render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
firstVisit.unmount();
expect(window.history.scrollRestoration).not.toBe("manual");
@@ -71,7 +71,7 @@ describe("home fragment navigation", () => {
scrollTo.mockClear();
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(scrollTo).not.toHaveBeenCalled();
expect(window.history.scrollRestoration).not.toBe("manual");
@@ -88,24 +88,24 @@ describe("home fragment navigation", () => {
position = 600;
fireEvent.wheel(window, { deltaY: 600 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(position).toBe(600);
});
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
it("waits for the home layout on fragment arrival, then never steals focus on refresh", () => {
window.history.replaceState(null, "", "/#contact");
render(<HomeNavigation />);
const contact = screen.getByRole("region", { name: "Contato" });
expect(contact).not.toHaveFocus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(contact).toHaveFocus();
screen.getByRole("button", { name: "Read biography" }).focus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(
screen.getByRole("button", { name: "Read biography" }),
@@ -119,7 +119,7 @@ describe("home fragment navigation", () => {
message.focus();
fireEvent.wheel(window, { deltaY: 200 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(message).toHaveFocus();
});
@@ -128,7 +128,7 @@ describe("home fragment navigation", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
const link = screen.getByRole("link", { name: "Sobre" });
link.focus();
@@ -152,7 +152,7 @@ describe("home fragment navigation", () => {
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(message).toHaveFocus();
});
+22 -28
View File
@@ -10,67 +10,61 @@ import Contact from "../Contact";
import EngineeringWithAI from "../EngineeringWithAI";
import Experience from "../Experience";
import Footer from "../Footer";
import Hero from "../Hero";
import Hero, { type HeroStats } from "../Hero";
import LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects";
import Skills from "../Skills";
import NotebookStory from "../story/NotebookStory";
import { WorkModalities } from "../WorkModalities";
import ScrollVelocityText from "./ScrollVelocityText";
import SectionDivider from "./SectionDivider";
import { useEffect, useRef } from "react";
import ExperienceDisclosure from "./ExperienceDisclosure";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
export default function CinematicHome({
projects,
experiences,
skillCategories,
heroStats,
}: {
heroStats?: HeroStats;
projects: PortfolioProjectView[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef);
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
useEffect(() => onLayoutReady(), [onLayoutReady]);
return (
<div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main">
<Hero />
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
<ScrollVelocityText text="SOFTWARE SOB MEDIDA" direction="left" />
</div>
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
<main ref={mainRef} className="ds-main relative z-0">
<Hero stats={heroStats} />
<Projects projects={projects} />
<SectionDivider variant="waveform" />
<WorkModalities />
<EngineeringWithAI />
<LighthouseMetrics />
<SectionDivider variant="pulse" />
<div className="bg-[var(--paper)]">
<About />
<About />
<details className="ds-section !pt-0">
<summary className="ds-section-shell cursor-pointer py-sp-5 text-lg font-semibold">
Experiência e tecnologias por trás das entregas
</summary>
<Skills categories={skillCategories} />
<Experience experiences={experiences} />
</details>
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText text="VAMOS CONSTRUIR" direction="left" />
<ExperienceDisclosure>
<Skills categories={skillCategories} />
<Experience experiences={experiences} />
</ExperienceDisclosure>
</div>
<Contact />
</main>
<Footer />
<div className="relative z-10">
<Footer />
</div>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
const TARGETS = new Set(["#experience", "#skills"]);
/**
* Skills e Experiência ficavam numa gaveta sempre fechada e sem link no menu.
* Agora abre sozinha em telas largas (onde há espaço) e quando um link aponta para #experience ou #skills;
* no celular continua recolhida para a página não crescer.
*/
export default function ExperienceDisclosure({
children,
}: {
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
useEffect(() => {
const reveal = (hash: string) => {
if (!TARGETS.has(hash)) return;
setOpen(true);
// espera o React abrir a gaveta para o alvo existir no layout
requestAnimationFrame(() =>
document
.getElementById(hash.slice(1))
?.scrollIntoView({ behavior: "instant", block: "start" }),
);
};
if (window.matchMedia("(min-width: 1024px)").matches) setOpen(true);
else if (TARGETS.has(window.location.hash)) setOpen(true);
const onClick = (event: MouseEvent) => {
const link =
event.target instanceof Element
? event.target.closest("a[href*='#']")
: null;
if (!(link instanceof HTMLAnchorElement)) return;
const url = new URL(link.href);
if (url.pathname === window.location.pathname) reveal(url.hash);
};
const onHashChange = () => reveal(window.location.hash);
document.addEventListener("click", onClick);
window.addEventListener("hashchange", onHashChange);
return () => {
document.removeEventListener("click", onClick);
window.removeEventListener("hashchange", onHashChange);
};
}, []);
return (
<details
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
className="ds-section !pt-0"
>
<summary className="ds-section-shell cursor-pointer py-sp-5 text-lg font-semibold">
Experiência e tecnologias por trás das entregas
</summary>
{children}
</details>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
const TAGS = [
["A tela", "o que sua equipe e seus clientes usam"],
["As integrações", "seus sistemas conversando entre si"],
["Os dados", "suas informações organizadas"],
] as const;
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
ssr: false,
});
function supportsWebGL2() {
if (typeof window.WebGL2RenderingContext === "undefined") return false;
try {
const context = document.createElement("canvas").getContext("webgl2");
context?.getExtension("WEBGL_lose_context")?.loseContext();
return context !== null;
} catch {
return false;
}
}
/**
* Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
*/
export default function HeroField() {
const rootRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const labelsRef = useRef<(HTMLElement | null)[]>([]);
const [mount, setMount] = useState(false);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
const span = Math.max(rect.height - window.innerHeight, window.innerHeight * 0.8);
const p = Math.min(1, Math.max(0, -rect.top / span));
progressRef.current = p;
section.style.setProperty("--hero-p", p.toFixed(4));
setDone(p >= 0.999);
};
const queue = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", queue, { passive: true });
window.addEventListener("resize", queue);
let intersecting = false;
const sync = () => setVisible(intersecting && !document.hidden);
const observer = new IntersectionObserver(([entry]) => {
intersecting = entry.isIntersecting;
sync();
});
observer.observe(section);
document.addEventListener("visibilitychange", sync);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", queue);
window.removeEventListener("resize", queue);
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
};
}, []);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!supportsWebGL2()) return;
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
if (typeof window.requestIdleCallback === "function") {
const id = window.requestIdleCallback(() => setMount(true), { timeout: 2500 });
return () => window.cancelIdleCallback(id);
}
const id = setTimeout(() => setMount(true), 1200);
return () => clearTimeout(id);
}, []);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => setFailed(true), []);
return (
<div
ref={rootRef}
aria-hidden="true"
className="hero-field"
data-ready={ready && !failed ? "" : undefined}
>
{mount && !failed && (
<Scene
progressRef={progressRef}
labelsRef={labelsRef}
visible={visible && !done}
onReady={handleReady}
onError={handleError}
/>
)}
{mount &&
!failed &&
TAGS.map(([title, text], index) => (
<span
key={title}
ref={(element) => {
labelsRef.current[index] = element;
}}
className="hero-tag"
>
<span className="hero-tag-title">{title}</span>
{text}
</span>
))}
</div>
);
}
+514
View File
@@ -0,0 +1,514 @@
"use client";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useMemo,
useRef,
} from "react";
import * as THREE from "three";
export type HeroFieldSceneProps = {
progressRef: RefObject<number>;
/** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
labelsRef: RefObject<(HTMLElement | null)[]>;
visible: boolean;
onReady: () => void;
onError: () => void;
};
/*
* Um sistema desenhado com pontos, em três camadas que um leigo reconhece:
* em cima, a TELA de um aplicativo (janela, título, botão, cards); no meio, as INTEGRAÇÕES (sistemas
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
* frente e a câmera atravessa uma a uma (as legendas de HeroField explicam cada camada nessa hora).
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
*/
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
type PointStyle = { tone?: number; size?: number; alpha?: number };
class FieldBuilder {
base: number[] = [];
to: number[] = [];
move: number[] = [];
tone: number[] = [];
size: number[] = [];
alpha: number[] = [];
constructor(private density: number) {}
point(x: number, y: number, z: number, style: PointStyle = {}, to?: [number, number, number]) {
this.base.push(x, y, z);
this.to.push(...(to ?? [x, y, z]));
this.move.push(to ? 1 : 0);
this.tone.push(style.tone ?? 0);
this.size.push(style.size ?? 0.02);
this.alpha.push(style.alpha ?? 0.7);
}
line(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
const s = step * this.density;
const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
for (let i = 0; i <= n; i++) this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
}
rect(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
this.line(y, x0, z0, x1, z0, step, style);
this.line(y, x1, z0, x1, z1, step, style);
this.line(y, x1, z1, x0, z1, step, style);
this.line(y, x0, z1, x0, z0, step, style);
}
fill(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
const s = step * this.density;
for (let x = x0; x <= x1 + 1e-6; x += s)
for (let z = z0; z <= z1 + 1e-6; z += s)
this.point(x + (Math.random() - 0.5) * s * 0.3, y, z + (Math.random() - 0.5) * s * 0.3, style);
}
ring(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
const n = Math.max(8, Math.round((Math.PI * 2 * r) / (step * this.density)));
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
}
}
disc(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
for (let rr = r; rr > 0.005; rr -= step * this.density) this.ring(y, cx, cz, rr, step, style);
this.point(cx, y, cz, style);
}
/** partículas que correm de a até b (a posição é calculada no shader) */
flow(a: [number, number, number], b: [number, number, number], count: number, style?: PointStyle) {
const n = Math.max(2, Math.round(count / this.density));
for (let i = 0; i < n; i++) this.point(...a, style, b);
}
}
// z negativo = topo da tela quando a camada fica de frente para a câmera
function drawScreen(f: FieldBuilder) {
const y: Layer = 1;
f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
for (const x of [-1.9, -1.72, -1.54]) f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, { tone: 1, alpha: 0.95, size: 0.022 }); // botão
f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
for (const [x0, x1] of [
[-1.8, -0.7],
[-0.55, 0.55],
[0.7, 1.8],
]) {
f.rect(y, x0, 0.45, x1, 1.2, 0.035, { alpha: 0.55 }); // cards
f.line(y, x0 + 0.12, 0.62, x1 - 0.3, 0.62, 0.045, { alpha: 0.5 });
f.line(y, x0 + 0.12, 0.76, x1 - 0.5, 0.76, 0.045, { alpha: 0.35 });
}
}
/** posições dos sistemas ligados à central; as colunas de dados sobem por eles */
const NODES: [number, number][] = Array.from({ length: 6 }, (_, k) => {
const a = (k / 6) * Math.PI * 2 + Math.PI / 6;
return [Math.cos(a) * 1.7, Math.sin(a) * 1.05];
});
function drawIntegrations(f: FieldBuilder) {
const y: Layer = 0;
f.ring(y, 0, 0, 0.34, 0.02, { tone: 1, alpha: 0.95, size: 0.024 }); // central
f.disc(y, 0, 0, 0.2, 0.035, { tone: 1, alpha: 0.5 });
NODES.forEach(([x, z], k) => {
f.ring(y, x, z, 0.2, 0.02, { tone: 1, alpha: 0.9, size: 0.022 });
f.disc(y, x, z, 0.1, 0.03, { alpha: 0.6 });
const len = Math.hypot(x, z);
const ux = x / len;
const uz = z / len;
// linha da central até o sistema, e mensagens correndo nos dois sentidos
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, { alpha: 0.35 });
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, { tone: 1, alpha: 0.95, size: 0.026 });
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, { alpha: 0.9, size: 0.024 });
const [nx, nz] = NODES[(k + 1) % NODES.length];
f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
});
}
function drawData(f: FieldBuilder) {
const y: Layer = -1;
const x0 = -2;
const x1 = 2;
const z0 = -1.3;
const z1 = 1.3;
const cols = [x0, -0.9, 0.2, 1.1, x1];
f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
for (let z = z0 + 0.32; z < z1; z += 0.33) f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
for (const x of cols.slice(1, -1)) f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
for (let z = z0 + 0.48; z < z1; z += 0.33) {
for (let c = 0; c < cols.length - 1; c++) {
const start = cols[c] + 0.12;
const width = (cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
}
}
}
function buildField(dense: boolean) {
const f = new FieldBuilder(dense ? 1 : 1.5);
drawScreen(f);
drawIntegrations(f);
drawData(f);
// colunas: a informação sobe dos dados, passa pelos sistemas e chega à tela
for (const [x, z] of [[0, 0] as [number, number], ...NODES])
f.flow([x, -1, z], [x, 1, z], 40, { tone: 1, alpha: 0.6, size: 0.022 });
const count = f.move.length;
const scatter = new Float32Array(count * 3);
const rand = new Float32Array(count * 4);
for (let n = 0; n < count; n++) {
const u = Math.random() * 2 - 1;
const t = Math.random() * Math.PI * 2;
const s = Math.sqrt(1 - u * u);
const d = 4 + Math.random() * 7;
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(f.base, 3));
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
return geometry;
}
const vertexShader = /* glsl */ `
uniform float uTime;
uniform float uIntro;
uniform float uSpread;
uniform float uScroll;
uniform float uScale;
uniform float uFocus;
uniform float uFade;
uniform vec2 uMouse;
uniform float uMouseAmt;
attribute vec3 aTo;
attribute float aMove;
attribute float aTone;
attribute float aSize;
attribute float aAlpha;
attribute vec3 aScatter;
attribute vec4 aRand;
varying vec3 vColor;
varying float vAlpha;
varying float vSoft;
const vec3 PAPER = vec3(0.957, 0.949, 0.929);
const vec3 LIME = vec3(0.8, 1.0, 0.0);
void main() {
// partículas em movimento correm de position até aTo e recomeçam
float f = fract(uTime * (0.1 + aRand.x * 0.08) + aRand.y);
vec3 b = mix(position, aTo, aMove * f);
vec3 p = vec3(b.x, b.y * uSpread, b.z);
float still = 1.0 - aMove;
float wave = sin(b.x * 1.7 + uTime * 0.55 + b.y * 2.1) * cos(b.z * 1.3 - uTime * 0.4);
p.y += wave * (0.02 + uScroll * 0.2) * still;
// cursor: uma onda que levanta a camada e acende os pontos em verde
float d = length(b.xz - uMouse);
float lift = exp(-d * d * 2.2) * uMouseAmt * still;
p.y += lift * 0.3 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05;
// entrada: cada ponto sai da nuvem com atraso próprio
float t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0);
float e = 1.0 - pow(1.0 - t, 4.0);
p = mix(aScatter, p, e);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
float depth = -mv.z;
float blur = clamp(abs(depth - uFocus) * 0.3, 0.0, 1.6);
float size = aSize * (0.8 + aRand.w * 0.4);
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.3), 64.0);
float alpha = aAlpha * mix(1.0, sin(f * 3.14159), aMove);
alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.5);
alpha *= smoothstep(0.25, 1.6, depth) * uFade;
vAlpha = alpha;
vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0);
vColor = mix(PAPER, LIME, clamp(aTone + lift * 1.6, 0.0, 1.0));
}
`;
const fragmentShader = /* glsl */ `
varying vec3 vColor;
varying float vAlpha;
varying float vSoft;
void main() {
float r = length(gl_PointCoord - 0.5) * 2.0;
float a = 1.0 - smoothstep(1.0 - vSoft, 1.0, r);
if (a <= 0.0) discard;
gl_FragColor = vec4(vColor, a * vAlpha);
}
`;
const smooth = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
/** Interpola entre quadros-chave [progresso, valor] com suavização em cada trecho. */
function keyframes(frames: readonly (readonly [number, number])[], p: number) {
if (p <= frames[0][0]) return frames[0][1];
for (let i = 1; i < frames.length; i++) {
const [p1, v1] = frames[i];
if (p <= p1) {
const [p0, v0] = frames[i - 1];
return v0 + (v1 - v0) * smooth(p0, p1, p);
}
}
return frames[frames.length - 1][1];
}
/** Distância mínima entre as camadas no fim do mergulho: cada uma vira um "cômodo" que a câmera visita. */
const SPREAD = 5.5;
/** Distância para enquadrar uma camada inteira numa tela larga. */
const FRAME = 5.2;
/*
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
* atravessa, enquadra os dados, atravessa. As legendas de Hero.tsx (--from/--to em globals.css) seguem
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
* camadas se afastam mais para a câmera caber entre elas.
*/
function cameraFrames(rest: number, spread: number, frame: number) {
return [
[0, rest],
[0.28, spread + frame],
[0.44, spread + frame * 0.87],
[0.5, spread - 0.3],
[0.55, frame],
[0.69, frame * 0.87],
[0.74, -0.3],
[0.78, -spread + frame],
[0.89, -spread + frame * 0.87],
[0.95, -spread - 0.3],
[1, -spread - 2],
] as const;
}
// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
const LABEL_ANCHORS: [number, Layer, number][] = [
[2.1, 1, 1.4],
[1.95, 0, 0.9],
[2, -1, 1.3],
];
function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps) {
const { gl, camera, size } = useThree();
const group = useRef<THREE.Group>(null);
const dense = useMemo(
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
[],
);
const geometry = useMemo(() => buildField(dense), [dense]);
const material = useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uIntro: { value: 0 },
uSpread: { value: 1.05 },
uScroll: { value: 0 },
uScale: { value: 1 },
uFocus: { value: 9 },
uFade: { value: 1 },
uMouse: { value: new THREE.Vector2(9, 9) },
uMouseAmt: { value: 0 },
},
}),
[],
);
const state = useRef({
progress: 0,
pointer: new THREE.Vector2(),
lastMove: -10,
mouse: new THREE.Vector2(),
tilt: new THREE.Vector2(),
frames: 0,
});
const helpers = useMemo(
() => ({
ray: new THREE.Raycaster(),
plane: new THREE.Plane(),
hit: new THREE.Vector3(),
anchor: new THREE.Vector3(),
zero: new THREE.Vector2(),
target: new THREE.Vector2(),
}),
[],
);
useEffect(() => {
const onMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
const s = state.current;
s.pointer.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
s.lastMove = performance.now() / 1000;
};
const onLost = (event: Event) => {
event.preventDefault();
onError();
};
window.addEventListener("pointermove", onMove, { passive: true });
gl.domElement.addEventListener("webglcontextlost", onLost);
return () => {
window.removeEventListener("pointermove", onMove);
gl.domElement.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
};
}, [gl, geometry, material, onError]);
useFrame((frame, delta) => {
const g = group.current;
if (!g) return;
const dt = Math.min(delta, 1 / 20);
const s = state.current;
const u = material.uniforms;
const time = frame.clock.elapsedTime;
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
s.progress += ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
const p = s.progress;
const aspect = size.width / size.height;
const wide = aspect > 1.1;
const tanHalf = Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
const rest = Math.max(9, frameDist * 1.1);
u.uTime.value = time;
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
u.uScroll.value = p;
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
const face = smooth(0.02, 0.28, p);
u.uSpread.value = 1.05 + (spreadMax - 1.05) * face * face;
u.uFade.value = 1 - smooth(0.93, 1, p);
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
u.uScale.value =
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME);
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
s.tilt.lerp(mouseActive ? s.pointer : helpers.zero, 1 - Math.exp(-dt * 3));
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
g.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + s.tilt.x * 0.15 * (1 - face),
0,
);
const settle = 1 - smooth(0, 0.28, p);
// em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
g.position.set(wide ? 1.7 * settle : 0, (wide ? -0.1 : -rest * tanHalf * 0.38) * settle, 0);
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
const spread = u.uSpread.value;
const ahead = [spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
g.updateMatrixWorld();
if (mouseActive) {
helpers.ray.setFromCamera(s.pointer, camera);
helpers.plane
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
.applyMatrix4(g.matrixWorld);
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
g.worldToLocal(helpers.hit);
s.mouse.lerp(helpers.target.set(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
}
} else {
s.mouse.lerp(
helpers.target.set(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.1),
1 - Math.exp(-dt * 1.5),
);
}
u.uMouse.value.copy(s.mouse);
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
const labels = labelsRef.current ?? [];
const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
const label = labels[index];
if (!label) return;
helpers.anchor.set(x, layer * u.uSpread.value, z).applyMatrix4(g.matrixWorld).project(camera);
const px = ((helpers.anchor.x + 1) / 2) * size.width;
const py = ((1 - helpers.anchor.y) / 2) * size.height;
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
label.style.opacity = shown.toFixed(3);
});
if (++s.frames === 2) onReady();
});
return (
<group ref={group}>
<points geometry={geometry} material={material} frustumCulled={false} />
</group>
);
}
class SceneBoundary extends Component<
{ children: ReactNode; onError: () => void },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onError();
}
render() {
return this.state.failed ? null : this.props.children;
}
}
export default function HeroFieldScene(props: HeroFieldSceneProps) {
return (
<SceneBoundary onError={props.onError}>
<Canvas
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
dpr={[1, 1.75]}
frameloop={props.visible ? "always" : "never"}
gl={{ alpha: true, antialias: false, powerPreference: "high-performance" }}
>
<Field {...props} />
</Canvas>
</SceneBoundary>
);
}
-325
View File
@@ -1,325 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import Image from "next/image";
import Link from "next/link";
import type { PointerEvent } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { HeroSystemLayer, HeroSystemSceneProps } from "./HeroSystemScene";
const DynamicScene = dynamic<HeroSystemSceneProps>(
() => import("./HeroSystemScene"),
{ ssr: false },
);
const LAYERS: { id: HeroSystemLayer; label: string; description: string }[] = [
{
id: "overview",
label: "Visão geral",
description:
"Da tela ao que acontece por trás: interface, integrações e dados trabalhando juntos.",
},
{
id: "interface",
label: "Interface",
description:
"A parte que as pessoas usam. Aqui, uma tela real do NutriScan, projeto do portfólio.",
},
{
id: "integrations",
label: "Integrações",
description:
"Conexões entre sistemas para levar informações de uma etapa da operação à próxima.",
},
{
id: "data",
label: "Dados",
description:
"A base que organiza e guarda as informações de que a operação precisa.",
},
];
const REST_POSE = { x: 0, y: 0 };
type Gesture = {
id: number;
x: number;
y: number;
startPose: number;
width: number;
dragging: boolean;
};
export default function HeroSystem() {
const descriptionId = useId();
const viewportRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<number | null>(null);
const nextPoseRef = useRef(REST_POSE);
const gestureRef = useRef<Gesture | null>(null);
const suppressClickRef = useRef(false);
const [activeLayer, setActiveLayer] = useState<HeroSystemLayer>("overview");
const [pose, setPose] = useState(REST_POSE);
const [canRender, setCanRender] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const [visible, setVisible] = useState(false);
const renderScene = canRender && !failed;
const sceneActive = renderScene && ready;
useEffect(() => {
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
let supported: boolean | undefined;
const updateCapability = () => {
if (preference.matches) {
setCanRender(false);
setReady(false);
return;
}
if (supported === undefined) {
if (typeof window.WebGL2RenderingContext === "undefined") {
supported = false;
} else {
try {
const canvas = document.createElement("canvas");
const context = canvas.getContext("webgl2");
supported = context !== null;
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
supported = false;
}
}
}
setCanRender(supported);
};
updateCapability();
preference.addEventListener("change", updateCapability);
return () => preference.removeEventListener("change", updateCapability);
}, []);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
let intersecting = false;
const updateVisibility = () => {
setVisible(intersecting && document.visibilityState === "visible");
};
const observer = new IntersectionObserver(([entry]) => {
intersecting = entry.isIntersecting;
updateVisibility();
});
observer.observe(viewport);
document.addEventListener("visibilitychange", updateVisibility);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", updateVisibility);
};
}, []);
const queuePose = useCallback((x: number, y: number) => {
nextPoseRef.current = {
x: Math.max(-1, Math.min(1, x)),
y: Math.max(-1, Math.min(1, y)),
};
if (frameRef.current !== null) return;
frameRef.current = window.requestAnimationFrame(() => {
frameRef.current = null;
setPose(nextPoseRef.current);
});
}, []);
const releaseGesture = useCallback(() => {
const gesture = gestureRef.current;
gestureRef.current = null;
const viewport = viewportRef.current;
if (gesture && viewport?.hasPointerCapture(gesture.id)) {
viewport.releasePointerCapture(gesture.id);
}
}, []);
useEffect(() => {
if (!sceneActive || !visible) {
releaseGesture();
if (frameRef.current !== null)
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
nextPoseRef.current = REST_POSE;
setPose(REST_POSE);
}
return () => {
releaseGesture();
if (frameRef.current !== null)
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
};
}, [sceneActive, visible, releaseGesture]);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => {
setFailed(true);
setReady(false);
}, []);
const selectSceneLayer = useCallback((layer: HeroSystemLayer) => {
if (!suppressClickRef.current) setActiveLayer(layer);
}, []);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!sceneActive || !visible || !event.isPrimary || event.button !== 0)
return;
suppressClickRef.current = false;
gestureRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
startPose: nextPoseRef.current.x,
width: event.currentTarget.clientWidth,
dragging: false,
};
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!sceneActive || !visible) return;
const gesture = gestureRef.current;
if (gesture && gesture.id === event.pointerId) {
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (!gesture.dragging) {
if (Math.abs(dy) > 7 && Math.abs(dy) > Math.abs(dx)) {
releaseGesture();
return;
}
if (Math.abs(dx) < 7) return;
gesture.dragging = true;
suppressClickRef.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
queuePose(gesture.startPose + (dx / Math.max(gesture.width, 1)) * 3, 0);
return;
}
if (event.pointerType !== "mouse" || event.buttons !== 0) return;
const bounds = event.currentTarget.getBoundingClientRect();
queuePose(
((event.clientX - bounds.left) / Math.max(bounds.width, 1)) * 2 - 1,
((event.clientY - bounds.top) / Math.max(bounds.height, 1)) * 2 - 1,
);
};
const cancelGesture = () => {
releaseGesture();
if (sceneActive && visible) queuePose(0, 0);
};
return (
<div className="hero-system min-w-0 w-full">
<div
ref={viewportRef}
className="hero-system-viewport relative isolate h-[350px] w-full overflow-hidden sm:h-[400px] lg:h-[440px]"
style={{
touchAction: "pan-y",
cursor: sceneActive ? "grab" : undefined,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={releaseGesture}
onPointerCancel={cancelGesture}
onLostPointerCapture={releaseGesture}
onPointerLeave={() => {
if (!gestureRef.current?.dragging) cancelGesture();
}}
onClickCapture={(event) => {
if (suppressClickRef.current) event.stopPropagation();
}}
>
<div
className={`absolute inset-0 flex items-center px-sp-3 ${sceneActive ? "pointer-events-none opacity-0" : "opacity-100"}`}
aria-hidden={sceneActive}
>
<div className="w-full">
<div className="mb-sp-3 flex items-center justify-between gap-sp-3 text-[12px] text-white/65">
<span className="font-medium text-[#f4f2ed]">
Interface real: NutriScan
</span>
</div>
<div className="relative block aspect-[2/1] overflow-hidden rounded-lg border border-white/15 bg-[#151a16] shadow-[0_24px_70px_rgba(0,0,0,0.45)]">
<Image
src="/assets/notebook/nutriscan.webp"
alt="Tela real do projeto NutriScan."
fill
priority
sizes="(min-width: 1024px) 44vw, 92vw"
className="object-cover object-top"
/>
</div>
</div>
</div>
{renderScene && (
<div
aria-hidden="true"
className={`absolute inset-0 ${sceneActive ? "opacity-100" : "pointer-events-none opacity-0"}`}
>
<DynamicScene
activeLayer={activeLayer}
pose={pose}
visible={visible}
onReady={handleReady}
onError={handleError}
onSelectLayer={selectSceneLayer}
/>
</div>
)}
{sceneActive && (
<p className="pointer-events-none absolute inset-x-0 bottom-sp-3 text-center text-[12px] text-white/60">
<span className="hidden sm:inline">
Mova ou arraste para explorar
</span>
<span className="sm:hidden">
Arraste para os lados para explorar
</span>
</p>
)}
</div>
<div className="relative px-sp-1 sm:px-sp-3">
<fieldset
aria-label="Explorar camadas do sistema"
className="grid grid-cols-2 gap-1 min-[400px]:grid-cols-4"
>
{LAYERS.map((layer) => (
<button
key={layer.id}
type="button"
aria-pressed={activeLayer === layer.id}
aria-controls={descriptionId}
onClick={() => {
suppressClickRef.current = false;
setActiveLayer(layer.id);
if (layer.id === "overview" && sceneActive && visible)
queuePose(0, 0);
}}
className={`min-h-11 rounded-sm px-sp-2 py-sp-2 text-[12px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none sm:text-[13px] ${activeLayer === layer.id ? "bg-[var(--slash)] text-[var(--ink)]" : "bg-white/5 text-white/75 hover:bg-white/10 hover:text-white"}`}
>
{layer.label}
</button>
))}
</fieldset>
<p
id={descriptionId}
aria-live="polite"
aria-atomic="true"
className="mt-sp-3 min-h-[4.5em] text-[13px] leading-[1.5] text-white/75 sm:min-h-[3em]"
>
{LAYERS.find((layer) => layer.id === activeLayer)?.description}
</p>
<div className="mt-sp-1 flex flex-wrap items-center justify-between gap-x-sp-3">
<p className="max-w-[230px] text-[11px] leading-[1.5] text-white/55">
Camadas ilustrativas de um sistema
</p>
<Link
href="/cases/nutriscan"
className="inline-flex min-h-11 items-center text-[12px] font-medium text-[#f4f2ed] underline decoration-white/35 underline-offset-4 hover:decoration-[var(--slash)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
Interface real: NutriScan
</Link>
</div>
</div>
</div>
);
}
@@ -1,577 +0,0 @@
"use client";
import { RoundedBox } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
Suspense,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import * as THREE from "three";
export type HeroSystemLayer =
| "overview"
| "interface"
| "integrations"
| "data";
export type HeroSystemSceneProps = {
activeLayer: HeroSystemLayer;
pose: { x: number; y: number };
visible: boolean;
onReady: () => void;
onError: () => void;
onSelectLayer: (layer: HeroSystemLayer) => void;
};
type LayerPose = readonly [number, number, number, number];
type SceneTextures = {
screen: THREE.CanvasTexture;
integrations: THREE.CanvasTexture;
data: THREE.CanvasTexture;
};
const LIME = "#ccff00";
const LAYERS = ["interface", "integrations", "data"] as const;
const LAYER_POSES: Record<HeroSystemLayer, readonly LayerPose[]> = {
overview: [
[-0.38, -0.42, 0.9, 1],
[0.12, 0.25, 0, 1],
[0.65, 0.92, -0.9, 1],
],
interface: [
[-0.1, -0.15, 1.3, 1.04],
[0.36, 0.6, -0.45, 0.91],
[0.8, 1.08, -1.25, 0.88],
],
integrations: [
[-0.82, -1.05, 1.05, 0.77],
[0.12, 0.55, 0.55, 1.02],
[0.76, 1.16, -1, 0.88],
],
data: [
[-0.9, -1.25, 1.02, 0.68],
[-0.44, -0.48, 0.18, 0.81],
[0.38, 1.08, 0.55, 1.02],
],
};
const NODE_X = [-1.28, 0, 1.28];
const SERVER_ROWS = [-0.47, -0.17, 0.13];
function canvasContext(width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is unavailable");
return { canvas, context };
}
function asTexture(canvas: HTMLCanvasElement) {
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 4;
return texture;
}
function createBoardTexture(layer: "integrations" | "data") {
const { canvas, context: ctx } = canvasContext(1024, 640);
ctx.fillStyle = "#121a1b";
ctx.fillRect(0, 0, 1024, 640);
ctx.strokeStyle = "#293737";
ctx.lineWidth = 1;
for (let x = 32; x < 1024; x += 40) {
for (let y = 32; y < 640; y += 40) {
ctx.beginPath();
ctx.moveTo(x - 2, y);
ctx.lineTo(x + 2, y);
ctx.stroke();
}
}
ctx.fillStyle = "#edf0e6";
ctx.font = "500 32px sans-serif";
ctx.fillText(layer === "integrations" ? "Integrações" : "Dados", 46, 61);
ctx.fillStyle = LIME;
ctx.fillRect(932, 43, 42, 5);
ctx.strokeStyle = "#455c50";
ctx.lineWidth = 3;
const endpoints = [175, 512, 849];
for (const x of endpoints) {
ctx.beginPath();
ctx.moveTo(x, 132);
ctx.lineTo(x, 231);
ctx.lineTo(512, 300);
ctx.lineTo(512, 490);
ctx.lineTo(x, 490);
ctx.lineTo(x, 556);
ctx.stroke();
ctx.fillStyle = LIME;
ctx.beginPath();
ctx.arc(x, 556, 6, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "#a6c748";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(52, 342);
ctx.lineTo(125, 342);
ctx.lineTo(206, 422);
ctx.lineTo(820, 422);
ctx.lineTo(899, 342);
ctx.lineTo(972, 342);
ctx.stroke();
return asTexture(canvas);
}
function useSceneTextures(onError: () => void) {
const [textures, setTextures] = useState<SceneTextures | null>(null);
useEffect(() => {
let cancelled = false;
const owned: THREE.Texture[] = [];
const image = new Image();
image.decoding = "async";
image.onload = () => {
if (cancelled) return;
try {
const { canvas, context } = canvasContext(1600, 800);
// Keep the actual product interface; exclude the old metrics footer.
context.drawImage(image, 0, 0, 1600, 800, 0, 0, 1600, 800);
const screen = asTexture(canvas);
owned.push(screen);
const integrations = createBoardTexture("integrations");
owned.push(integrations);
const data = createBoardTexture("data");
owned.push(data);
setTextures({ screen, integrations, data });
} catch {
onError();
}
};
image.onerror = () => {
if (!cancelled) onError();
};
image.src = "/assets/notebook/nutriscan.webp";
return () => {
cancelled = true;
image.onload = null;
image.onerror = null;
image.removeAttribute("src");
for (const texture of owned) texture.dispose();
};
}, [onError]);
return textures;
}
function Plate({ selected }: { selected: boolean }) {
return (
<>
<RoundedBox args={[4.28, 2.72, 0.14]} radius={0.06} smoothness={4}>
<meshStandardMaterial
color={selected ? "#93ad76" : "#687579"}
metalness={0.62}
roughness={0.29}
/>
</RoundedBox>
<RoundedBox
args={[4.2, 2.64, 0.08]}
radius={0.03}
smoothness={4}
position={[0, 0, 0.075]}
>
<meshStandardMaterial color="#111919" metalness={0.3} roughness={0.4} />
</RoundedBox>
<mesh position={[0, -1.3, 0.124]}>
<boxGeometry args={[3.8, 0.018, 0.014]} />
<meshBasicMaterial color={selected ? LIME : "#667451"} />
</mesh>
{[-1.96, 1.96].map((x) => (
<mesh key={x} position={[x, 1.18, 0.13]}>
<circleGeometry args={[0.025, 12]} />
<meshStandardMaterial
color="#b6c0b7"
metalness={0.8}
roughness={0.3}
/>
</mesh>
))}
</>
);
}
function InterfacePlate({ texture }: { texture: THREE.Texture }) {
return (
<>
<RoundedBox
args={[4.02, 2.09, 0.035]}
position={[0, -0.075, 0.139]}
radius={0.012}
smoothness={3}
>
<meshStandardMaterial
color="#dfe5d7"
metalness={0.2}
roughness={0.38}
/>
</RoundedBox>
<mesh position={[0, -0.075, 0.16]}>
<planeGeometry args={[3.94, 1.97]} />
<meshBasicMaterial map={texture} toneMapped={false} />
</mesh>
<mesh position={[-1.84, 1.16, 0.13]}>
<circleGeometry args={[0.025, 16]} />
<meshBasicMaterial color={LIME} />
</mesh>
<mesh position={[-1.6, 1.16, 0.13]}>
<boxGeometry args={[0.28, 0.018, 0.012]} />
<meshBasicMaterial color="#b3bcb3" />
</mesh>
<mesh position={[0, 1.16, 0.13]}>
<boxGeometry args={[1.35, 0.12, 0.015]} />
<meshStandardMaterial color="#2b3633" roughness={0.7} />
</mesh>
</>
);
}
function IntegrationNodes() {
return (
<>
{NODE_X.map((x) => (
<group key={x} position={[x, 0.45, 0.21]}>
<RoundedBox args={[0.7, 0.52, 0.14]} radius={0.06} smoothness={3}>
<meshStandardMaterial
color="#d6ddcf"
metalness={0.5}
roughness={0.28}
/>
</RoundedBox>
<mesh position={[0, 0, 0.077]}>
<planeGeometry args={[0.53, 0.35]} />
<meshStandardMaterial
color="#25382d"
roughness={0.36}
metalness={0.35}
/>
</mesh>
<mesh position={[0, 0, 0.083]}>
<boxGeometry args={[0.2, 0.035, 0.01]} />
<meshBasicMaterial color={LIME} />
</mesh>
</group>
))}
<RoundedBox
args={[1.03, 0.68, 0.2]}
radius={0.055}
smoothness={3}
position={[0, -0.38, 0.24]}
>
<meshStandardMaterial
color="#89a962"
metalness={0.55}
roughness={0.32}
/>
</RoundedBox>
<mesh position={[0, -0.38, 0.345]}>
<planeGeometry args={[0.77, 0.44]} />
<meshStandardMaterial color="#15251c" metalness={0.2} roughness={0.5} />
</mesh>
{[-0.15, 0, 0.15].map((y) => (
<mesh key={y} position={[0, -0.38 + y, 0.352]}>
<boxGeometry args={[0.43, 0.022, 0.01]} />
<meshBasicMaterial color={LIME} />
</mesh>
))}
</>
);
}
function DataVolumes() {
return (
<>
{[-1.25, 0].map((x) => (
<group key={x} position={[x, 0.03, 0.3]}>
<RoundedBox args={[0.86, 1.24, 0.3]} radius={0.065} smoothness={3}>
<meshStandardMaterial
color="#51645f"
metalness={0.6}
roughness={0.3}
/>
</RoundedBox>
{SERVER_ROWS.map((y) => (
<group key={y} position={[0, y + 0.17, 0.159]}>
<mesh>
<boxGeometry args={[0.7, 0.21, 0.035]} />
<meshStandardMaterial
color="#142020"
metalness={0.3}
roughness={0.45}
/>
</mesh>
<mesh position={[-0.25, 0, 0.022]}>
<circleGeometry args={[0.021, 12]} />
<meshBasicMaterial color={LIME} />
</mesh>
<mesh position={[0.06, 0, 0.022]}>
<boxGeometry args={[0.34, 0.025, 0.013]} />
<meshStandardMaterial
color="#a6b3a5"
metalness={0.6}
roughness={0.35}
/>
</mesh>
</group>
))}
</group>
))}
<group position={[1.28, 0.02, 0.59]}>
{[-0.2, 0, 0.2].map((y) => (
<group key={y} position={[0, y, 0]}>
<mesh>
<cylinderGeometry args={[0.43, 0.43, 0.17, 40]} />
<meshStandardMaterial
color="#a8b6a4"
metalness={0.62}
roughness={0.29}
/>
</mesh>
<mesh position={[0, 0.078, 0]}>
<cylinderGeometry args={[0.435, 0.435, 0.015, 40]} />
<meshBasicMaterial color={LIME} />
</mesh>
</group>
))}
</group>
</>
);
}
function SystemAssembly(
props: HeroSystemSceneProps & {
textures: SceneTextures;
reducedMotion: boolean;
},
) {
const { textures, reducedMotion } = props;
const root = useRef<THREE.Group>(null);
const plates = useRef<(THREE.Group | null)[]>([]);
const entrance = useRef(0);
const ready = useRef(false);
const failed = useRef(false);
const { camera, gl, invalidate, size } = useThree();
useLayoutEffect(() => {
if (!(camera instanceof THREE.PerspectiveCamera)) return;
camera.zoom = Math.min(1.1, size.width / Math.max(size.height, 1) / 1.12);
camera.updateProjectionMatrix();
if (props.visible) invalidate();
}, [camera, invalidate, props.visible, size.height, size.width]);
useEffect(() => {
if (props.visible) invalidate();
}, [invalidate, props]);
// Render on demand and stop scheduling as soon as every transform settles.
useFrame((state, delta) => {
if (
!props.visible ||
document.hidden ||
failed.current ||
!root.current ||
gl.getContext().isContextLost()
)
return;
const dt = Math.min(delta, 1 / 30);
entrance.current = reducedMotion
? 1
: Math.min(1, entrance.current + dt / 1.05);
const assembly = (1 - entrance.current) ** 3;
const alpha = reducedMotion ? 1 : 1 - Math.exp(-11 * dt);
const targetX = -0.12 + Math.max(-1, Math.min(1, props.pose.y)) * 0.11;
const targetY = -0.36 + Math.max(-1, Math.min(1, props.pose.x)) * 0.16;
let moving = entrance.current < 1;
const rotation = root.current.rotation;
rotation.x += (targetX - rotation.x) * alpha;
rotation.y += (targetY - rotation.y) * alpha;
if (
Math.abs(targetX - rotation.x) + Math.abs(targetY - rotation.y) >
0.0004
)
moving = true;
else {
rotation.x = targetX;
rotation.y = targetY;
}
const targets = LAYER_POSES[props.activeLayer];
for (let index = 0; index < LAYERS.length; index++) {
const plate = plates.current[index];
if (!plate) continue;
const target = targets[index];
const x = target[0] + (index - 1) * assembly * 0.65;
const y = target[1] + (index - 1) * assembly * 0.5;
const z = target[2] - (3 - index) * assembly * 0.8;
plate.position.x += (x - plate.position.x) * alpha;
plate.position.y += (y - plate.position.y) * alpha;
plate.position.z += (z - plate.position.z) * alpha;
const scale = plate.scale.x + (target[3] - plate.scale.x) * alpha;
plate.scale.setScalar(scale);
const remaining =
Math.abs(x - plate.position.x) +
Math.abs(y - plate.position.y) +
Math.abs(z - plate.position.z) +
Math.abs(target[3] - scale);
if (remaining > 0.0008) moving = true;
else {
plate.position.set(x, y, z);
plate.scale.setScalar(target[3]);
}
}
try {
gl.render(state.scene, camera);
if (!ready.current && !gl.getContext().isContextLost()) {
ready.current = true;
props.onReady();
}
} catch {
failed.current = true;
props.onError();
return;
}
if (moving) invalidate();
}, 1);
return (
<>
<ambientLight intensity={1.15} />
<hemisphereLight args={["#f6faed", "#283b30", 1.8]} />
<directionalLight position={[1, 5, 6]} color="#f6f9ef" intensity={3.7} />
<directionalLight position={[-5, 1, 2]} color={LIME} intensity={2.1} />
<directionalLight position={[4, -1, -3]} color="#e0e8df" intensity={3} />
<group
ref={root}
rotation={[-0.12, -0.36, -0.055]}
position={[0, -0.07, 0]}
>
{LAYERS.map((layer, index) => (
<group
key={layer}
ref={(group) => {
plates.current[index] = group;
}}
position={[
LAYER_POSES.overview[index][0],
LAYER_POSES.overview[index][1] + (index - 1) * 0.5,
LAYER_POSES.overview[index][2] - (3 - index) * 0.8,
]}
onPointerUp={(event) => {
event.stopPropagation();
props.onSelectLayer(layer);
}}
>
<Plate
selected={
props.activeLayer === layer || props.activeLayer === "overview"
}
/>
{layer === "interface" ? (
<InterfacePlate texture={textures.screen} />
) : (
<>
<mesh position={[0, 0, 0.12]}>
<planeGeometry args={[4.02, 2.5]} />
<meshBasicMaterial map={textures[layer]} toneMapped={false} />
</mesh>
{layer === "integrations" ? (
<IntegrationNodes />
) : (
<DataVolumes />
)}
</>
)}
</group>
))}
</group>
</>
);
}
function SceneContent(
props: HeroSystemSceneProps & { reducedMotion: boolean },
) {
const textures = useSceneTextures(props.onError);
const gl = useThree((state) => state.gl);
useEffect(() => {
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
props.onError();
};
canvas.addEventListener("webglcontextlost", handleContextLost);
return () =>
canvas.removeEventListener("webglcontextlost", handleContextLost);
}, [gl, props.onError]);
return textures ? <SystemAssembly {...props} textures={textures} /> : null;
}
class SceneBoundary extends Component<
{ children: ReactNode; onError: () => void },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onError();
}
render() {
return this.state.failed ? null : this.props.children;
}
}
export default function HeroSystemScene(props: HeroSystemSceneProps) {
const [tabVisible, setTabVisible] = useState(false);
const [started, setStarted] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const updateVisibility = () => setTabVisible(!document.hidden);
const updateMotion = () => setReducedMotion(media.matches);
updateVisibility();
updateMotion();
document.addEventListener("visibilitychange", updateVisibility);
media.addEventListener("change", updateMotion);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
media.removeEventListener("change", updateMotion);
};
}, []);
const visible = props.visible && tabVisible;
useEffect(() => {
if (visible) setStarted(true);
}, [visible]);
if (!started) return null;
return (
<SceneBoundary onError={props.onError}>
<Canvas
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
camera={{ fov: 32, position: [0, 0, 10], near: 0.1, far: 40 }}
dpr={[1, 1.5]}
frameloop={visible ? "demand" : "never"}
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
>
<Suspense fallback={null}>
<SceneContent
{...props}
visible={visible}
reducedMotion={reducedMotion}
/>
</Suspense>
</Canvas>
</SceneBoundary>
);
}
@@ -1,76 +0,0 @@
"use client";
import { useEffect, useId, useState } from "react";
interface ScrollVelocityTextProps {
text: string;
direction: "left" | "right";
className?: string;
}
const GROUPS = [0, 1] as const;
export default function ScrollVelocityText({
text,
direction,
className = "",
}: ScrollVelocityTextProps) {
const trackId = useId();
const [isPaused, setIsPaused] = useState(false);
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
setShouldReduceMotion(media.matches);
const updatePreference = (event: MediaQueryListEvent) => {
setShouldReduceMotion(event.matches);
};
media.addEventListener("change", updatePreference);
return () => media.removeEventListener("change", updatePreference);
}, []);
const animationPaused = shouldReduceMotion || isPaused;
return (
<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>
))}
</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>
);
}
@@ -1,44 +0,0 @@
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 rootClassName =
`section-divider section-divider-${variant} ${className}`.trim();
if (variant === "gradient") {
return (
<div aria-hidden="true" className={rootClassName}>
<span />
<span />
</div>
);
}
if (variant === "pulse") {
return (
<div aria-hidden="true" className={rootClassName}>
{PULSE_DOTS.map((dot) => (
<span key={dot} />
))}
</div>
);
}
return (
<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>
);
}
@@ -0,0 +1,270 @@
"use client";
import { type RefObject, useEffect } from "react";
type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) => void;
/*
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
* da rolagem (rolar para cima volta o "vídeo").
* Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada
* para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada
* "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais.
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1);
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
* - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências.
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
*/
const vh = () => window.innerHeight;
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
export function pageTop(el: HTMLElement) {
let top = 0;
for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null)
top += node.offsetTop;
return top;
}
// a seção que sai fica parada na tela enquanto a próxima entra
const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
tl.fromTo(prev, { y: 0 }, { y: vh }, 0);
// a seção que entra também fica parada no topo: a transição acontece "no lugar"
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
const band = (open: number) => () =>
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
const center = () => `at 50% ${vh() / 2}px`;
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 1.18, opacity: 0.2 },
0,
)
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo(
next,
{ xPercent: 100 },
{ xPercent: 0, ease: "power2.out" },
0,
);
},
// 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);
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
next,
{ yPercent: 0, y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
},
// 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: 20 }, 0).fromTo(
next,
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
{ xPercent: 0, rotation: 0, ease: "power2.out" },
0,
);
},
// sobre → contato: o contato cresce do centro, como um zoom de câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.92, opacity: 0.2 },
0,
)
.fromTo(
next,
{
scale: 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => `50% ${vh() / 2}px`,
},
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
0,
)
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
},
];
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cancelled = false;
let cleanup = () => {};
(async () => {
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
import("lenis"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
const scenes = Array.from(main.children) as HTMLElement[];
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 });
});
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
const next = scenes[index + 1];
if (!prev || !next) return;
const tl = gsap.timeline({
defaults: { ease: "none", duration: 1 },
scrollTrigger: {
trigger: next,
start: () => pageTop(next) - vh(),
end: () => pageTop(next),
scrub: true,
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);
});
}, main);
// Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na
// tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior;
// depois não aparece atrás de transparências, e o navegador não pinta o que está coberto.
let tops: number[] = [];
const syncVisibility = () => {
const y = window.scrollY;
scenes.forEach((scene, index) => {
const from = index === 0 ? -Infinity : tops[index] - vh();
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility) scene.style.visibility = visibility;
});
};
const measure = () => {
tops = scenes.map(pageTop);
syncVisibility();
};
measure();
ScrollTrigger.addEventListener("refresh", measure);
window.addEventListener("scroll", syncVisibility, { passive: true });
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self")) return;
const url = new URL(link.href);
if (url.origin !== location.origin || url.pathname !== location.pathname || !url.hash)
return;
let target: HTMLElement | null = null;
try {
target = document.getElementById(decodeURIComponent(url.hash.slice(1)));
} catch {}
if (!target) return;
event.preventDefault();
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
// alvos internos descontam o cabeçalho fixo
const scene = scenes.find((candidate) => candidate.contains(target));
const offset =
Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
const top =
!scene || scene === scenes[0]
? 0
: Math.max(pageTop(scene), scene === target ? 0 : pageTop(target) - offset);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
lenis.scrollTo(top, {
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
easing: easeInOutCubic,
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
onComplete: () => {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
},
});
};
document.addEventListener("click", onClick, true);
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", measure);
window.removeEventListener("scroll", syncVisibility);
context.revert();
for (const scene of scenes) scene.style.visibility = "";
gsap.ticker.remove(tick);
lenis.destroy();
};
})();
return () => {
cancelled = true;
cleanup();
};
}, [mainRef]);
}
@@ -1,285 +0,0 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(getFeaturedProjects(null));
const frame: NotebookBrowserProps["frame"] = {
cursorVisible: true,
cursorX: 0.4,
cursorY: 0.6,
cursorClicking: false,
};
function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
return render(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
{...overrides}
/>,
);
}
function imageFor(container: HTMLElement, index: number) {
const source = new URL(projects[index].screenshotSrc, document.baseURI).href;
const image = Array.from(container.querySelectorAll("img")).find(
(candidate) => candidate.src === source,
);
if (!image) throw new Error(`Missing screenshot for ${projects[index].id}`);
return image;
}
describe("NotebookBrowser", () => {
it("renders the active project as an accessible tabpanel", () => {
renderBrowser({ activeIndex: 1 });
expect(
screen.getByRole("tablist", { name: /projetos em produção/i }),
).toBeInTheDocument();
expect(screen.getAllByRole("tab")[1]).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SC Plus Aquicultura/i,
);
expect(screen.getByRole("status")).toHaveTextContent(
"Projeto ativo: SC Plus Aquicultura",
);
});
it("keeps decoded content visible and ignores a superseded decode", async () => {
const view = renderBrowser();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
let finishDecode!: () => void;
imageFor(view.container, 1).decode = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishDecode = resolve;
}),
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={2}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 2));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
await act(async () => {
finishDecode();
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
});
it("keeps the last capture with a truthful notice when the requested image fails", async () => {
const view = renderBrowser();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
fireEvent.error(imageFor(view.container, 1));
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
expect(
screen.getByText(/não foi possível carregar.*SC Plus/i),
).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/SC Plus/i);
});
it("can reverse to an already-loaded outgoing image without another load event", async () => {
const view = renderBrowser();
const first = imageFor(view.container, 0);
Object.defineProperties(first, {
complete: { value: true },
naturalWidth: { value: 1280 },
});
await act(async () => {
fireEvent.load(first);
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
expect(
await screen.findByRole("img", { name: /NutriScan/i }),
).toBeInTheDocument();
});
it("moves between tabs with arrow keys and Home/End", async () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[1]);
await user.keyboard("{ArrowRight}");
await user.keyboard("{Home}");
await user.keyboard("{End}");
expect(onSelectTab).toHaveBeenNthCalledWith(1, 1);
expect(onSelectTab).toHaveBeenNthCalledWith(2, 2);
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("moves DOM focus with the active tab", async () => {
const user = userEvent.setup();
const tabs =
renderBrowser().container.querySelectorAll<HTMLButtonElement>(
'[role="tab"]',
);
tabs[0].focus();
await user.keyboard("{ArrowRight}");
expect(document.activeElement).toBe(tabs[1]);
});
it("removes the closed browser from the accessibility tree", () => {
renderBrowser({ interactive: false });
const browser = screen.getByTestId("notebook-browser");
expect(browser).toHaveAttribute("aria-hidden", "true");
expect(browser).toHaveAttribute("inert");
});
it("bounds arrow navigation at the first and last tab", async () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[0]);
await user.keyboard("{ArrowLeft}");
await user.click(tabs.at(-1) as HTMLElement);
await user.keyboard("{ArrowRight}");
expect(onSelectTab).toHaveBeenNthCalledWith(2, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
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.*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("uses the selected tab to label the panel and decoded screenshot", async () => {
const view = renderBrowser({ activeIndex: 2 });
await act(async () => {
fireEvent.load(imageFor(view.container, 2));
});
const tab = screen.getAllByRole("tab")[2];
const panel = screen.getByRole("tabpanel");
const image = screen.getByRole("img");
expect(tab).toHaveAttribute("aria-controls", panel.id);
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
expect(image).toHaveProperty(
"src",
new URL(projects[2].screenshotSrc, document.baseURI).href,
);
});
});
-427
View File
@@ -1,427 +0,0 @@
"use client";
import Image from "next/image";
import {
type Ref,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { NotebookProject } from "./notebookProjects";
import type { NotebookFrame } from "./notebookTimeline";
type CursorFrame = Pick<
NotebookFrame,
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
export type NotebookBrowserHandle = {
updateFrame: (frame: CursorFrame) => void;
};
export type NotebookBrowserProps = {
projects: NotebookProject[];
activeIndex: number;
frame: CursorFrame;
ref?: Ref<NotebookBrowserHandle>;
onSelectTab: (index: number) => void;
interactive?: boolean;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
function toDomId(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
export default function NotebookBrowser({
projects,
activeIndex,
frame,
onSelectTab,
interactive = true,
ref,
}: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject =
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
const rootRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<SVGSVGElement>(null);
const cursorFrame = useRef(frame);
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
const browserSize = useRef({ width: 0, height: 0 });
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const requestedSrc = selectedProject?.screenshotSrc;
const requestRef = useRef(requestedSrc);
const requestVersion = useRef(0);
const displayedRef = useRef(displayed);
const mounted = useRef(true);
useLayoutEffect(() => {
requestRef.current = requestedSrc;
requestVersion.current += 1;
setFailedSrc(null);
}, [requestedSrc]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
// Only the immediate neighbours are fetched; the browser cache handles reuse.
useEffect(() => {
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
.filter((project): project is NotebookProject => Boolean(project))
.map((project) => {
const image = new window.Image();
image.decoding = "async";
image.src = project.screenshotSrc;
return image;
});
return () => {
images.forEach((image) => {
image.removeAttribute("src");
});
};
}, [projects, selectedIndex]);
const updateFrame = useCallback((next: CursorFrame) => {
cursorFrame.current = next;
const cursor = cursorRef.current;
const points = tabPoints.current;
if (!cursor || points.length === 0) return;
const position = Math.max(
0,
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
);
const left = Math.floor(position);
const right = Math.min(left + 1, points.length - 1);
const blend = position - left;
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
cursor.style.opacity = next.cursorVisible ? "1" : "0";
cursor.classList.toggle("is-clicking", next.cursorClicking);
}, []);
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
const measure = () => {
browserSize.current = {
width: root.clientWidth,
height: root.clientHeight,
};
const points = [];
for (const project of projects) {
const tab: HTMLButtonElement | null =
root.querySelector<HTMLButtonElement>(
`#notebook-tab-${toDomId(project.id)}`,
);
if (!tab) continue;
let x = tab.offsetWidth / 2;
let y = tab.offsetHeight / 2;
let element: HTMLElement | null = tab;
while (element && element !== root) {
x += element.offsetLeft;
y += element.offsetTop;
element = element.offsetParent as HTMLElement | null;
}
points.push({ x, y });
}
tabPoints.current = points;
updateFrame(cursorFrame.current);
};
measure();
if (!("ResizeObserver" in window)) return undefined;
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [projects, updateFrame]);
useLayoutEffect(() => {
updateFrame(frame);
}, [frame, updateFrame]);
const revealImage = useCallback(
async (image: HTMLImageElement, project: NotebookProject) => {
const version = requestVersion.current;
try {
if (typeof image.decode === "function") await image.decode();
} catch {
if (
mounted.current &&
version === requestVersion.current &&
requestRef.current === project.screenshotSrc
) {
setFailedSrc(project.screenshotSrc);
}
return;
}
if (
!mounted.current ||
version !== requestVersion.current ||
requestRef.current !== project.screenshotSrc
)
return;
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
setOutgoing(
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
? null
: displayedRef.current,
);
displayedRef.current = project;
setDisplayed(project);
setFailedSrc(null);
},
[],
);
useLayoutEffect(() => {
if (!selectedProject) return;
// A quick reversal can select the still-mounted outgoing image: no new load event.
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
.href;
const image = Array.from(
rootRef.current?.querySelectorAll("img") ?? [],
).find((item) => item.src === source);
if (image?.complete && image.naturalWidth > 0) {
void revealImage(image, selectedProject);
}
}, [selectedProject, revealImage]);
const imageProjects = [outgoing, displayed, selectedProject].filter(
(project, index, list): project is NotebookProject =>
Boolean(project) &&
list.findIndex(
(item) => item?.screenshotSrc === project?.screenshotSrc,
) === index,
);
const waiting = displayed?.screenshotSrc !== requestedSrc;
const interactionProps = interactive
? {}
: {
"aria-hidden": true,
inert: true,
};
function handleTabKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number,
) {
if (projects.length < 1) return;
let nextIndex: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
nextIndex = Math.min(index + 1, projects.length - 1);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
nextIndex = Math.max(index - 1, 0);
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = projects.length - 1;
}
if (nextIndex === null) return;
event.preventDefault();
event.currentTarget.parentElement
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus({ preventScroll: true });
onSelectTab(nextIndex);
}
if (!selectedProject) {
return (
<div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
data-empty
>
<p aria-live="polite">Nenhum projeto disponível para esta cena.</p>
</div>
);
}
const selectedId = toDomId(selectedProject.id);
const panelId = `notebook-panel-${selectedId}`;
return (
<div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
ref={rootRef}
>
<div className="notebook-browser-bar" aria-hidden="true">
<span className="notebook-browser-dot" />
<span className="notebook-browser-dot" />
<span className="notebook-browser-dot" />
<span className="notebook-browser-address">
evandro.dev.br / projetos
</span>
</div>
<div className="notebook-browser-body">
<div className="notebook-browser-heading">
<span className="notebook-browser-eyebrow">Projetos em produção</span>
<span className="notebook-browser-counter">
{String(selectedIndex + 1).padStart(2, "0")} /{" "}
{String(projects.length).padStart(2, "0")}
</span>
</div>
<output className="sr-only" aria-live="polite">
Projeto ativo: {selectedProject.title}
</output>
<div
className="notebook-tablist"
role="tablist"
aria-label="Projetos em produção"
>
{projects.map((project, index) => {
const tabId = `notebook-tab-${toDomId(project.id)}`;
const isSelected = index === selectedIndex;
return (
<button
aria-controls={panelId}
aria-selected={isSelected}
className="notebook-tab"
id={tabId}
key={project.id}
onClick={() => onSelectTab(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
role="tab"
tabIndex={isSelected ? 0 : -1}
type="button"
>
<span className="notebook-tab-number">
{String(index + 1).padStart(2, "0")}
</span>
<span className="notebook-tab-title">{project.title}</span>
</button>
);
})}
</div>
<div
aria-labelledby={`notebook-tab-${selectedId}`}
className="notebook-panel"
id={panelId}
role="tabpanel"
>
<div className="notebook-panel-copy">
<h3>{selectedProject.title}</h3>
<p>{selectedProject.subtitle}</p>
<ul aria-label={`Tecnologias de ${selectedProject.title}`}>
{selectedProject.technologies.slice(0, 4).map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
{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"
aria-busy={waiting && failedSrc !== requestedSrc}
>
{imageProjects.map((project) => {
const current =
project.screenshotSrc === displayed?.screenshotSrc;
const previous =
project.screenshotSrc === outgoing?.screenshotSrc;
return (
<Image
key={project.screenshotSrc}
alt={current ? `Captura de tela de ${project.title}` : ""}
aria-hidden={!current}
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
width={project.screenshotWidth}
height={project.screenshotHeight}
loading="eager"
sizes="(min-width: 1025px) 800px, 100vw"
src={project.screenshotSrc}
unoptimized
onLoad={(event) => {
void revealImage(event.currentTarget, project);
}}
onError={() => {
if (project.screenshotSrc === requestRef.current)
setFailedSrc(project.screenshotSrc);
}}
onAnimationEnd={() => {
if (current) setOutgoing(null);
}}
/>
);
})}
{waiting ? (
<p className="notebook-screenshot-status" aria-live="polite">
{failedSrc === requestedSrc
? `Não foi possível carregar a captura de ${selectedProject.title}.`
: `Carregando captura de ${selectedProject.title}…`}
{displayed
? ` Exibindo ${displayed.title} enquanto isso.`
: null}
</p>
) : null}
</div>
</div>
</div>
<svg
aria-hidden="true"
className="notebook-cursor"
ref={cursorRef}
viewBox="0 0 28 36"
width="28"
height="36"
>
<title>Cursor decorativo</title>
<path
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
fill="#0a0a0a"
stroke="#f4f2ed"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<circle
className="notebook-cursor-pulse"
cx="2"
cy="2"
r="8"
fill="none"
stroke="#ccff00"
strokeWidth="2"
/>
</svg>
</div>
);
}
@@ -1,43 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(getFeaturedProjects(null));
describe("NotebookFallback", () => {
it("keeps project content and controls in normal document flow", () => {
const { container } = render(<NotebookFallback projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /abrir projeto/i }),
).toBeInTheDocument();
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("allows manual tab changes without an animated stage", async () => {
const user = userEvent.setup();
const { container } = render(<NotebookFallback projects={projects} />);
await user.click(screen.getAllByRole("tab")[2]);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SEV Moda Íntima/i,
);
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("respects the requested initial project", () => {
render(<NotebookFallback initialIndex={3} projects={projects} />);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/AS Serviços/i);
expect(screen.getAllByRole("tab")[3]).toHaveAttribute(
"aria-selected",
"true",
);
});
});
-52
View File
@@ -1,52 +0,0 @@
"use client";
import { useState } from "react";
import NotebookBrowser from "./NotebookBrowser";
import type { NotebookProject } from "./notebookProjects";
export type NotebookFallbackProps = {
projects: NotebookProject[];
initialIndex?: number;
onSelectTab?: (index: number) => void;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
export default function NotebookFallback({
projects,
initialIndex = 0,
onSelectTab,
}: NotebookFallbackProps) {
const [activeIndex, setActiveIndex] = useState(() =>
clampIndex(initialIndex, projects.length),
);
return (
<div className="notebook-fallback" data-testid="notebook-fallback">
<div className="notebook-fallback-intro">
<span className="notebook-fallback-kicker">Interface em produção</span>
<p>
Navegue pelos projetos com as abas abaixo. A experiência permanece
completa sem movimento ou WebGL.
</p>
</div>
<NotebookBrowser
activeIndex={activeIndex}
frame={{
cursorClicking: false,
cursorVisible: false,
cursorX: 0,
cursorY: 0,
}}
onSelectTab={(index) => {
setActiveIndex(index);
onSelectTab?.(index);
}}
projects={projects}
/>
</div>
);
}
@@ -1,131 +0,0 @@
import { render, screen } from "@testing-library/react";
import * as THREE from "three";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStage3D, {
getNotebookLidRotation,
type NotebookStage3DProps,
updateNotebookScreenMatrix,
} from "./NotebookStage3D";
import { selectNotebookProjects } from "./notebookProjects";
type MockCanvasProps = {
children: React.ReactNode;
frameloop?: string;
};
const mockCanvas = jest.fn(({ children, frameloop }: MockCanvasProps) => (
<div data-frameloop={frameloop} data-testid="mock-canvas">
{children}
</div>
));
jest.mock("@react-three/fiber", () => ({
Canvas: (props: MockCanvasProps) => mockCanvas(props),
useFrame: jest.fn(),
}));
jest.mock("@react-three/drei", () => ({
Html: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-html">{children}</div>
),
RoundedBox: () => <div data-testid="mock-roundedbox" />,
}));
const projects = selectNotebookProjects(getFeaturedProjects(null));
const props: NotebookStage3DProps = {
activeIndex: 0,
isVisible: true,
onSelectTab: jest.fn(),
progress: { current: 0.56 },
requestRender: { current: null },
projects,
};
describe("getNotebookLidRotation", () => {
it("keeps the lid closed at rest and opens around the hinge", () => {
expect(getNotebookLidRotation(0)).toBe(0);
expect(getNotebookLidRotation(112)).toBeCloseTo(
-THREE.MathUtils.degToRad(112),
6,
);
expect(getNotebookLidRotation(56)).toBeCloseTo(
-THREE.MathUtils.degToRad(56),
6,
);
});
it("clamps angles outside the physical hinge range", () => {
expect(getNotebookLidRotation(-10)).toBe(0);
expect(getNotebookLidRotation(200)).toBe(getNotebookLidRotation(180));
});
});
it("keeps browser corners on the physical LCD after opening and resizing", () => {
const matrix = new THREE.Matrix4();
const rig = new THREE.Group();
rig.position.set(0, -0.45, 0);
const hinge = new THREE.Group();
hinge.position.set(0, 0.16, -1.06);
rig.add(hinge);
const lcd = new THREE.Object3D();
lcd.position.set(0, -0.028, 1.1);
lcd.rotation.x = Math.PI / 2;
hinge.add(lcd);
const domWidth = 800;
const domHeight = (domWidth * 1.86) / 2.8;
for (const size of [
{ width: 939.390625, height: 738, lidAngle: 74 },
{ width: 1123.375, height: 629.75, lidAngle: 112 },
]) {
const camera = new THREE.PerspectiveCamera(30, size.width / size.height);
camera.position.set(0, 1.65, 5.7);
camera.rotation.x = -0.19;
hinge.rotation.x = getNotebookLidRotation(size.lidAngle);
updateNotebookScreenMatrix(matrix, lcd, camera, size);
for (const [u, v] of [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
]) {
const expected = new THREE.Vector3((u - 0.5) * 2.8, (0.5 - v) * 1.86, 0)
.applyMatrix4(lcd.matrixWorld)
.project(camera);
const actual = new THREE.Vector3(
u * domWidth,
v * domHeight,
0,
).applyMatrix4(matrix);
expect(actual.x).toBeCloseTo(((expected.x + 1) * size.width) / 2, 6);
expect(actual.y).toBeCloseTo(((1 - expected.y) * size.height) / 2, 6);
}
}
});
describe("NotebookStage3D", () => {
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("returns usable browser content when the stage boundary fails", () => {
const onStageError = jest.fn();
mockCanvas.mockImplementation(() => {
throw new Error("canvas failed");
});
render(<NotebookStage3D {...props} onStageError={onStageError} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /abrir projeto/i }),
).toBeInTheDocument();
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
expect(onStageError).toHaveBeenCalledTimes(1);
});
});
-468
View File
@@ -1,468 +0,0 @@
"use client";
import { Html, RoundedBox } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import * as THREE from "three";
import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
import NotebookFallback from "./NotebookFallback";
import type { NotebookProject } from "./notebookProjects";
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
type ProgressRef = { current: number };
export type NotebookStage3DProps = {
progress: ProgressRef;
requestRender: { current: (() => void) | null };
projects: NotebookProject[];
activeIndex: number;
onSelectTab: (index: number) => void;
isVisible: boolean;
onStageError?: () => void;
};
type NotebookSceneProps = NotebookStage3DProps;
/**
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
* resting on the deck), 112° = open (screen facing the camera, reclined
* ~22° past vertical). The lid pivots around the hinge axis — never around
* the display center.
*/
export function getNotebookLidRotation(lidAngle: number): number {
const angle = THREE.MathUtils.clamp(lidAngle, 0, 180);
const radians = THREE.MathUtils.degToRad(angle);
return radians === 0 ? 0 : -radians;
}
// The laptop and its DOM screen share the same world-space geometry.
const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
const CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
const HINGE: [number, number, number] = [0, 0.16, -1.06];
const LID = { width: 3.2, thickness: 0.1, depth: 2.2 };
const SCREEN_INSET = { width: 2.9, depth: 1.96 };
const SCREEN_GLASS = { width: 2.8, depth: 1.86 };
const KEY_COLUMNS = 12;
const KEY_ROWS = 5;
const KEY_SIZE: [number, number, number] = [0.185, 0.05, 0.155];
const KEY_GAP_X = 0.212;
const KEY_GAP_Z = 0.2;
const KEYBOARD_Z_START = -0.9;
const KEYBOARD_Y = 0.185;
const SPACEBAR_Z = 0.12;
const SCREEN_DOM_WIDTH = 800;
const SCREEN_DOM_HEIGHT =
(SCREEN_DOM_WIDTH * SCREEN_GLASS.depth) / SCREEN_GLASS.width;
const SCREEN_PIXELS_TO_WORLD = new THREE.Matrix4()
.makeScale(
SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
-SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
1,
)
.setPosition(-SCREEN_GLASS.width / 2, SCREEN_GLASS.depth / 2, 0);
const SCREEN_ORIGIN: [number, number] = [0, 0];
const getScreenOrigin = () => SCREEN_ORIGIN;
export function updateNotebookScreenMatrix(
matrix: THREE.Matrix4,
screen: THREE.Object3D,
camera: THREE.Camera,
size: { width: number; height: number },
): THREE.Matrix4 {
screen.updateWorldMatrix(true, false);
camera.updateMatrixWorld();
const halfWidth = size.width / 2;
const halfHeight = size.height / 2;
// One pixel-space matrix keeps CSS paint and hit-testing on the LCD plane.
matrix
.set(
halfWidth,
0,
0,
halfWidth,
0,
-halfHeight,
0,
halfHeight,
0,
0,
1,
0,
0,
0,
0,
1,
)
.multiply(camera.projectionMatrix)
.multiply(camera.matrixWorldInverse)
.multiply(screen.matrixWorld)
.multiply(SCREEN_PIXELS_TO_WORLD);
return matrix.multiplyScalar(1 / matrix.elements[15]);
}
type LaptopMaterials = {
chassis: THREE.MeshStandardMaterial;
keys: THREE.MeshStandardMaterial;
dark: THREE.MeshStandardMaterial;
accent: THREE.MeshStandardMaterial;
screen: THREE.MeshStandardMaterial;
};
function createLaptopMaterials(): LaptopMaterials {
return {
chassis: new THREE.MeshStandardMaterial({
color: "#3f545b",
metalness: 0.35,
roughness: 0.32,
transparent: true,
}),
keys: new THREE.MeshStandardMaterial({
color: "#5a747c",
metalness: 0.2,
roughness: 0.5,
transparent: true,
}),
dark: new THREE.MeshStandardMaterial({
color: "#131f24",
metalness: 0.15,
roughness: 0.55,
transparent: true,
}),
accent: new THREE.MeshStandardMaterial({
color: "#465f66",
metalness: 0.3,
roughness: 0.4,
transparent: true,
}),
screen: new THREE.MeshStandardMaterial({
color: "#0d1417",
emissive: "#22343a",
emissiveIntensity: 0.55,
metalness: 0.1,
roughness: 0.4,
transparent: true,
}),
};
}
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
materials.chassis.opacity = opacity;
materials.keys.opacity = opacity;
materials.dark.opacity = opacity;
materials.accent.opacity = opacity;
materials.screen.opacity = opacity;
}
function disposeLaptopMaterials(materials: LaptopMaterials) {
for (const material of Object.values(materials)) {
material.dispose();
}
}
function LaptopKeys({ material }: { material: THREE.MeshStandardMaterial }) {
const ref = useRef<THREE.InstancedMesh>(null);
const positions = useMemo(() => {
const list: Array<[number, number]> = [];
const startX = (-(KEY_COLUMNS - 1) * KEY_GAP_X) / 2;
for (let row = 0; row < KEY_ROWS; row += 1) {
for (let column = 0; column < KEY_COLUMNS; column += 1) {
list.push([
startX + column * KEY_GAP_X,
KEYBOARD_Z_START + row * KEY_GAP_Z,
]);
}
}
return list;
}, []);
useLayoutEffect(() => {
const mesh = ref.current;
if (!(mesh instanceof THREE.InstancedMesh)) return;
const matrix = new THREE.Matrix4();
positions.forEach(([x, z], index) => {
matrix.makeTranslation(x, KEYBOARD_Y, z);
mesh.setMatrixAt(index, matrix);
});
mesh.instanceMatrix.needsUpdate = true;
}, [positions]);
return (
<instancedMesh
args={[undefined, undefined, positions.length]}
material={material}
ref={ref}
>
<boxGeometry args={KEY_SIZE} />
</instancedMesh>
);
}
function LaptopBase({ materials }: { materials: LaptopMaterials }) {
return (
<group>
<RoundedBox
args={[BASE.width, BASE.height, BASE.depth]}
material={materials.chassis}
position={[0, BASE.height / 2, 0]}
radius={0.05}
smoothness={3}
/>
<RoundedBox
args={[2.66, 0.05, 1.3]}
material={materials.dark}
position={[0, 0.155, -0.38]}
radius={0.02}
smoothness={2}
/>
<LaptopKeys material={materials.keys} />
<mesh material={materials.keys} position={[0, KEYBOARD_Y, SPACEBAR_Z]}>
<boxGeometry args={[1.05, KEY_SIZE[1], KEY_SIZE[2]]} />
</mesh>
<RoundedBox
args={[1.15, 0.02, 0.6]}
material={materials.accent}
position={[0, 0.17, 0.68]}
radius={0.01}
smoothness={2}
/>
<mesh
material={materials.dark}
position={[0, 0.16, -1.06]}
rotation={[0, 0, Math.PI / 2]}
>
<cylinderGeometry args={[0.05, 0.05, 2.9, 16]} />
</mesh>
</group>
);
}
function LaptopLid({ materials }: { materials: LaptopMaterials }) {
return (
<group>
<RoundedBox
args={[LID.width, LID.thickness, LID.depth]}
material={materials.chassis}
position={[0, LID.thickness / 2, LID.depth / 2]}
radius={0.05}
smoothness={3}
/>
<RoundedBox
args={[SCREEN_INSET.width, 0.05, SCREEN_INSET.depth]}
material={materials.dark}
position={[0, 0, LID.depth / 2]}
radius={0.02}
smoothness={2}
/>
</group>
);
}
function ScreenSurface({
frame,
material,
projects,
activeIndex,
onSelectTab,
}: {
frame: RefObject<NotebookFrame>;
material: THREE.MeshStandardMaterial;
projects: NotebookProject[];
activeIndex: number;
onSelectTab: (index: number) => void;
}) {
const screen = useRef<THREE.Group>(null);
const surface = useRef<HTMLDivElement>(null);
const browser = useRef<NotebookBrowserHandle>(null);
const projection = useMemo(() => new THREE.Matrix4(), []);
useFrame(({ camera, size }) => {
if (surface.current && screen.current) {
updateNotebookScreenMatrix(projection, screen.current, camera, size);
surface.current.style.transform = `matrix3d(${projection.elements.join(",")})`;
surface.current.style.opacity = String(frame.current.screenOpacity);
surface.current.inert = frame.current.screenOpacity < 0.95;
surface.current.setAttribute(
"aria-hidden",
String(frame.current.screenOpacity < 0.95),
);
}
browser.current?.updateFrame(frame.current);
});
return (
<group
ref={screen}
position={[0, -0.028, LID.depth / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<mesh material={material}>
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
</mesh>
<Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
<div
ref={surface}
className="notebook-screen-surface"
style={{
width: SCREEN_DOM_WIDTH,
height: SCREEN_DOM_HEIGHT,
opacity: frame.current.screenOpacity,
}}
inert={frame.current.screenOpacity < 0.95}
aria-hidden={frame.current.screenOpacity < 0.95}
>
<NotebookBrowser
ref={browser}
activeIndex={activeIndex}
frame={frame.current}
onSelectTab={onSelectTab}
projects={projects}
/>
</div>
</Html>
</group>
);
}
function NotebookScene({
activeIndex,
onSelectTab,
progress,
projects,
requestRender,
isVisible,
onStageError,
}: NotebookSceneProps) {
const lidRig = useRef<THREE.Group>(null);
const frame = useRef(getNotebookFrame(progress.current, projects.length));
const materials = useMemo(createLaptopMaterials, []);
const { camera, gl, size, invalidate } = useThree();
useLayoutEffect(() => {
if (!(camera instanceof THREE.PerspectiveCamera)) return;
// Preserve desktop framing; widen portrait views without stretching the LCD.
camera.zoom =
window.innerWidth <= 1024
? Math.min(1, size.width / Math.max(size.height, 1) / 1.5)
: 1;
camera.updateProjectionMatrix();
invalidate();
}, [camera, invalidate, size.width, size.height]);
useEffect(() => {
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
onStageError?.();
};
canvas.addEventListener("webglcontextlost", handleContextLost);
return () =>
canvas.removeEventListener("webglcontextlost", handleContextLost);
}, [gl, onStageError]);
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
useEffect(() => {
requestRender.current = isVisible ? invalidate : null;
if (isVisible) invalidate();
return () => {
requestRender.current = null;
};
}, [invalidate, isVisible, requestRender]);
useFrame(() => {
const nextFrame = getNotebookFrame(
progress.current,
projects.length,
frame.current,
);
if (lidRig.current) {
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
}
setLaptopOpacity(materials, nextFrame.deviceOpacity);
}, -1);
return (
<group position={RIG_POSITION}>
<ambientLight intensity={1.35} />
<hemisphereLight args={["#f4f2ed", "#0a0a0a", 1.15]} />
<directionalLight color="#f4f2ed" intensity={2.6} position={[3, 6, 6]} />
<directionalLight color="#ccff00" intensity={0.7} position={[-5, 2, 3]} />
<LaptopBase materials={materials} />
<group position={HINGE} ref={lidRig}>
<LaptopLid materials={materials} />
<ScreenSurface
activeIndex={activeIndex}
frame={frame}
material={materials.screen}
onSelectTab={onSelectTab}
projects={projects}
/>
</group>
</group>
);
}
class NotebookStageBoundary extends Component<
NotebookStage3DProps,
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onStageError?.();
}
render(): ReactNode {
if (this.state.failed) {
return (
<NotebookFallback
initialIndex={this.props.activeIndex}
projects={this.props.projects}
/>
);
}
return (
<Canvas
camera={{
fov: 30,
position: CAMERA_POSITION,
rotation: CAMERA_ROTATION,
}}
className="notebook-story-canvas"
dpr={[1, 1.5]}
frameloop={this.props.isVisible ? "demand" : "never"}
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
}}
>
<NotebookScene {...this.props} />
</Canvas>
);
}
}
export default function NotebookStage3D(props: NotebookStage3DProps) {
return (
<div className="notebook-story-stage" data-testid="notebook-stage">
<NotebookStageBoundary {...props} />
</div>
);
}
-265
View File
@@ -1,265 +0,0 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, { canUseNotebook3D } 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(props: { isVisible: boolean }) {
return mockNotebookStage(props);
};
});
jest.mock("gsap", () => ({
gsap: {
registerPlugin: jest.fn(),
timeline: jest.fn(() => ({ to: jest.fn().mockReturnThis() })),
context: jest.fn((callback: () => void) => {
callback();
return { revert: jest.fn() };
}),
},
}));
jest.mock("gsap/ScrollTrigger", () => ({
ScrollTrigger: { getAll: jest.fn(() => []) },
}));
const projects = getFeaturedProjects(null);
let intersectionCallback: IntersectionObserverCallback | undefined;
let reducedMotionListener: (() => void) | 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,
coarse = false,
hover = true,
}: {
reduced?: boolean;
coarse?: boolean;
hover?: boolean;
} = {}) {
const matcher = jest.isMockFunction(window.matchMedia)
? window.matchMedia
: jest.spyOn(window, "matchMedia");
matcher.mockImplementation((query: string) => ({
matches:
(query === "(prefers-reduced-motion: reduce)" && reduced) ||
(query === "(pointer: coarse)" && coarse) ||
(query === "(hover: hover)" && hover),
media: query,
onchange: null,
addEventListener: jest.fn((event: string, callback: () => void) => {
if (query === "(prefers-reduced-motion: reduce)" && event === "change") {
reducedMotionListener = callback;
}
}),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
}
describe("NotebookStory", () => {
beforeEach(() => {
mockMatchMedia();
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1280,
});
setDocumentHidden(false);
intersectionCallback = undefined;
reducedMotionListener = 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(() => {
jest.restoreAllMocks();
});
it("renders the static fallback when reduced motion is enabled", async () => {
mockMatchMedia({ reduced: true });
render(<NotebookStory projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
});
it("initializes the 3D stage on a narrow touch viewport without hover", async () => {
mockMatchMedia({ coarse: true, hover: false });
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 390,
});
render(<NotebookStory projects={projects} />);
expect(await screen.findByTestId("notebook-stage")).toBeInTheDocument();
expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument();
});
it("lets touch users select and open a project outside the scaled screen", async () => {
mockMatchMedia({ coarse: true, hover: false });
render(<NotebookStory projects={projects} />);
await screen.findByTestId("notebook-stage");
const controls = screen.getByRole("navigation", {
name: "Navegar projetos do notebook",
});
const button = within(controls).getByRole("button", {
name: /Mostrar SC/i,
});
fireEvent.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
expect(
within(controls).getByRole("link", { name: /Abrir projeto/i }),
).toHaveAttribute(
"href",
projects.find((project) => project.id === "sc-plus")?.demoUrl,
);
});
it("returns to static content if reduced motion is enabled during the story", async () => {
mockMatchMedia({ coarse: true, hover: false });
render(<NotebookStory projects={projects} />);
await screen.findByTestId("notebook-stage");
mockMatchMedia({ reduced: true, coarse: true, hover: false });
act(() => reducedMotionListener?.());
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument();
});
it("initializes the 3D stage on a desktop viewport", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
);
});
it("stays paused when the tab returns while the section is offscreen", async () => {
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");
});
it("keeps the mounted 3D stage when resizing from desktop to touch mobile", async () => {
render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 360,
});
mockMatchMedia({ coarse: true, hover: false });
act(() => window.dispatchEvent(new Event("resize")));
expect(screen.getByTestId("notebook-stage")).toBe(stage);
expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument();
});
it("keeps the static fallback when no mapped projects are available", async () => {
mockMatchMedia();
render(<NotebookStory projects={[]} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(
screen.getByLabelText("Projetos em produção no notebook"),
).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
});
it("respects reduced motion independently of width and pointer capabilities", () => {
const matchMedia = (query: string) =>
({
matches: query === "(hover: hover)",
}) as MediaQueryList;
expect(canUseNotebook3D({ matchMedia })).toBe(true);
expect(
canUseNotebook3D({
matchMedia: (query) =>
({
matches:
query === "(hover: hover)" ||
query === "(prefers-reduced-motion: reduce)",
}) as MediaQueryList,
}),
).toBe(false);
});
it("passes only mapped projects into the fallback browser", () => {
mockMatchMedia({ reduced: true });
render(<NotebookStory projects={projects} />);
expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: /Twins Lab/i }),
).not.toBeInTheDocument();
});
});
-330
View File
@@ -1,330 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
ssr: false,
});
type CapabilityWindow = Pick<Window, "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProjectView[];
onLayoutReady?: () => void;
};
export function canUseNotebook3D(view: CapabilityWindow): boolean {
if (typeof view.matchMedia !== "function") return false;
return !view.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function getNotebookRunwayVh(tabCount: number): number {
const count = Math.max(
1,
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
);
return 220 + count * 85;
}
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
export default function NotebookStory({
projects,
onLayoutReady,
}: NotebookStoryProps) {
const notebookProjects = useMemo(
() => selectNotebookProjects(projects),
[projects],
);
const sectionRef = useRef<HTMLElement>(null);
const progressRef = useRef(0);
const requestRenderRef = useRef<(() => void) | null>(null);
const seekRef = useRef<((progress: number) => void) | null>(null);
const layoutReadyRef = useRef(onLayoutReady);
const failedRef = useRef(false);
const frameRef = useRef(getNotebookFrame(0, notebookProjects.length));
const [capabilityResolved, setCapabilityResolved] = useState(false);
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
const [engine, setEngine] = useState(false);
const [sectionIntersects, setSectionIntersects] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() => typeof document === "undefined" || !document.hidden,
);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => {
layoutReadyRef.current = onLayoutReady;
}, [onLayoutReady]);
useEffect(() => {
if (!engine && capabilityResolved) layoutReadyRef.current?.();
}, [engine, capabilityResolved]);
useEffect(() => {
const updateEngine = () => {
const nextEngine =
!failedRef.current &&
notebookProjects.length > 0 &&
canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine);
setCapabilityResolved(true);
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.addEventListener("change", updateEngine);
return () => {
reducedMotion.removeEventListener("change", updateEngine);
};
}, [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) return undefined;
if (!("IntersectionObserver" in window)) {
setSectionIntersects(true);
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
{ threshold: 0 },
);
observer.observe(section);
return () => observer.disconnect();
}, [engine]);
useEffect(() => {
if (!engine) return undefined;
const sectionElement = sectionRef.current;
if (!sectionElement) return undefined;
const sectionRoot: HTMLElement = sectionElement;
let cancelled = false;
let cleanup = () => {};
async function setupTimeline() {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const context = gsap.context(() => {
const driver = { value: 0 };
const applyProgress = () => {
progressRef.current = driver.value;
const nextIndex = getNotebookFrame(
driver.value,
notebookProjects.length,
frameRef.current,
).activeTabIndex;
if (nextIndex !== activeIndexRef.current) {
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
}
requestRenderRef.current?.();
};
const timeline = gsap.timeline({
defaults: { ease: "none" },
onUpdate: applyProgress,
scrollTrigger: {
trigger: sectionRoot,
start: "top top",
end: "bottom bottom",
scrub: true,
invalidateOnRefresh: true,
onRefresh: () => layoutReadyRef.current?.(),
},
});
timeline.to(driver, { duration: 1, value: 1 });
seekRef.current = (progress) => {
const trigger = timeline.scrollTrigger;
if (!trigger) return;
// Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs.
window.scrollTo({
top: trigger.start + (trigger.end - trigger.start) * progress,
behavior: "instant",
});
ScrollTrigger.update();
timeline.progress(progress);
applyProgress();
};
timeline.scrollTrigger?.refresh();
}, sectionRoot);
cleanup = () => {
seekRef.current = null;
context.revert();
};
}
void setupTimeline().catch(() => {
if (!cancelled) {
failedRef.current = true;
setEngine(false);
}
});
return () => {
cancelled = true;
cleanup();
};
}, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => {
failedRef.current = true;
setEngine(false);
}, []);
const handleSelectTab = useCallback(
(index: number) => {
const safeIndex = clampIndex(index, notebookProjects.length);
if (safeIndex < 0) return;
activeIndexRef.current = safeIndex;
setActiveIndex(safeIndex);
if (engine) {
seekRef.current?.(
getNotebookSeekProgress(safeIndex, notebookProjects.length),
);
}
},
[engine, notebookProjects.length],
);
const activeProject = notebookProjects[activeIndex];
if (!engine) {
return (
<section
aria-label="Projetos em produção no notebook"
className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 0 ? " notebook-story-pending" : ""}`}
data-testid="notebook-story"
id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
} as CSSProperties
}
>
<div className="notebook-story-static-content">
<div className="notebook-story-copy">
<span className="notebook-story-kicker">O notebook</span>
<h2>Software que opera.</h2>
<p>
Uma seleção navegável de sistemas reais, com suas interfaces e
tecnologias em produção.
</p>
</div>
<NotebookFallback
initialIndex={activeIndex}
onSelectTab={handleSelectTab}
projects={notebookProjects}
/>
</div>
</section>
);
}
return (
<section
aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-runway"
data-notebook-engine="3d"
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
data-testid="notebook-story"
id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
} as CSSProperties
}
>
<div className="notebook-story-sticky">
<div className="notebook-story-grid">
<div className="notebook-story-copy">
<span className="notebook-story-kicker">O notebook</span>
<h2>Software que opera.</h2>
<p>
Role para abrir a engenharia por trás de cada entrega. O scroll é
o controle de play e rebobinar.
</p>
<span className="notebook-story-count" aria-live="polite">
{String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "}
{String(notebookProjects.length).padStart(2, "0")}
</span>
</div>
<NotebookStage3D
activeIndex={activeIndex}
isVisible={sectionIntersects && isPageVisible}
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
requestRender={requestRenderRef}
projects={notebookProjects}
/>
<nav
className="notebook-story-controls"
aria-label="Navegar projetos do notebook"
>
<div className="notebook-story-project-buttons">
{notebookProjects.map((project, index) => (
<button
key={project.id}
type="button"
aria-label={`Mostrar ${project.title}`}
aria-pressed={index === activeIndex}
onClick={() => handleSelectTab(index)}
>
{String(index + 1).padStart(2, "0")}
</button>
))}
</div>
<p aria-live="polite">{activeProject?.title}</p>
{activeProject?.demoAvailability === "unavailable" ? (
<span aria-disabled="true">
Demo indisponível na última verificação
</span>
) : activeProject?.demoAvailability !== "none" &&
activeProject?.demoUrl ? (
<a
href={activeProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto
{activeProject.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
— Disponibilidade não verificada
</span>
)}
</a>
) : null}
</nav>
</div>
</div>
</section>
);
}
@@ -1,82 +0,0 @@
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import { selectNotebookProjects } from "./notebookProjects";
const projectsFixture = getFeaturedProjects(null);
describe("selectNotebookProjects", () => {
it("returns featured projects in notebook order", () => {
const result = selectNotebookProjects(projectsFixture);
expect(result.map((project) => project.id)).toEqual([
"nutriscan",
"sc-plus",
"sev-modaintima",
"as-servicos",
]);
});
it("omits a project when its screenshot mapping is missing", () => {
const result = selectNotebookProjects([
...projectsFixture,
{ ...projectsFixture[0], id: "unknown-live-project" },
]);
expect(
result.some((project) => project.id === "unknown-live-project"),
).toBe(false);
});
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("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(
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",
});
});
});
-68
View File
@@ -1,68 +0,0 @@
import type { PortfolioProjectView } from "@/lib/portfolio-data";
export type NotebookProject = {
id: string;
title: string;
subtitle: string;
demoUrl: PortfolioProjectView["demoUrl"];
demoAvailability: PortfolioProjectView["demoAvailability"];
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
technologies: string[];
screenshotSrc: string;
screenshotWidth: number;
screenshotHeight: number;
};
const NOTEBOOK_ORDER = [
"nutriscan",
"sc-plus",
"sev-modaintima",
"as-servicos",
] as const;
const SCREENSHOT_BY_PROJECT_ID: Record<
(typeof NOTEBOOK_ORDER)[number],
string
> = {
nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/notebook/as-servicos.webp",
};
function isApprovedDemoUrl(
value: PortfolioProjectView["demoUrl"],
): value is string {
return typeof value === "string" && /^https?:\/\//.test(value);
}
export function selectNotebookProjects(
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 ||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
) {
return [];
}
return [
{
id: project.id,
title: project.title,
subtitle: project.subtitle,
demoUrl: project.demoUrl,
demoAvailability: project.demoAvailability,
demoCheckedAt: project.demoCheckedAt,
technologies: [...project.technologies],
screenshotSrc,
screenshotWidth: 1600,
screenshotHeight: 1000,
},
];
});
}
@@ -1,75 +0,0 @@
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
describe("notebook choreography", () => {
it("opens and boots before browsing, then blanks the display before closing", () => {
const frames = Array.from({ length: 1001 }, (_, index) =>
getNotebookFrame(index / 1000, 4),
);
const phases = [...new Set(frames.map((frame) => frame.phase))];
expect(phases).toEqual([
"approach",
"open",
"boot",
"cursor-in",
"browse",
"hold",
"close",
]);
for (const frame of frames) {
if (frame.phase === "open") expect(frame.screenOpacity).toBe(0);
if (frame.cursorVisible) expect(frame.screenOpacity).toBe(1);
if (frame.phase === "close" && frame.lidAngle < 112)
expect(frame.screenOpacity).toBe(0);
}
expect(getNotebookFrame(-1, 4).lidAngle).toBe(0);
expect(getNotebookFrame(2, 4).lidAngle).toBe(0);
});
it("keeps the previous project until cursor arrival, including reverse scroll", () => {
const frames = Array.from({ length: 10001 }, (_, index) =>
getNotebookFrame(index / 10000, 4),
);
for (let selected = 1; selected < 4; selected += 1) {
const arrival = frames.findIndex(
(frame) => frame.activeTabIndex === selected,
);
const before = getNotebookFrame((arrival - 1) / 10000, 4);
const atClick = frames[arrival];
expect(before.activeTabIndex).toBe(selected - 1);
expect(before.cursorClicking).toBe(false);
expect(atClick.cursorClicking).toBe(true);
expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
expect(reversed.activeTabIndex).toBe(selected - 1);
expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
}
});
it("seeks directly into a readable hold for the requested project", () => {
for (const count of [1, 4]) {
for (let index = 0; index < count; index += 1) {
const progress = getNotebookSeekProgress(index, count);
const frame = getNotebookFrame(progress, count);
expect(frame.activeTabIndex).toBe(index);
expect(frame.screenOpacity).toBe(1);
expect(frame.cursorClicking).toBe(false);
expect(getNotebookFrame(progress + 0.03, count).activeTabIndex).toBe(
index,
);
}
}
expect(
getNotebookFrame(getNotebookSeekProgress(99, 4), 4).activeTabIndex,
).toBe(3);
});
it("does not expose a tab or cursor for empty or invalid project counts", () => {
expect(getNotebookFrame(0.6, 0)).toMatchObject({
activeTabIndex: -1,
cursorVisible: false,
cursorClicking: false,
});
expect(getNotebookFrame(0.6, Number.NaN).activeTabIndex).toBe(-1);
});
});
-117
View File
@@ -1,117 +0,0 @@
export type NotebookPhase =
| "approach"
| "open"
| "boot"
| "cursor-in"
| "browse"
| "hold"
| "close";
export type NotebookFrame = {
phase: NotebookPhase;
lidAngle: number;
deviceOpacity: number;
screenOpacity: number;
activeTabIndex: number;
cursorVisible: boolean;
cursorX: number;
cursorY: number;
cursorClicking: boolean;
};
const OPEN_START = 0.06;
const BOOT_START = 0.18;
const CURSOR_START = 0.23;
const BROWSE_START = 0.3;
const BROWSE_END = 0.84;
const CLOSE_START = 0.9;
const CLOSE_LID_START = 0.93;
const OPEN_ANGLE = 112;
const ARRIVAL = 0.18;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function between(value: number, start: number, end: number): number {
return clamp((value - start) / (end - start), 0, 1);
}
export function getNotebookSeekProgress(
index: number,
tabCount: number,
): number {
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
if (!count) return BROWSE_START;
const selected = clamp(
Number.isFinite(index) ? Math.floor(index) : 0,
0,
count - 1,
);
return (
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
);
}
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
export function getNotebookFrame(
progress: number,
tabCount: number,
target?: NotebookFrame,
): NotebookFrame {
const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
const phase: NotebookPhase =
p < OPEN_START
? "approach"
: p < BOOT_START
? "open"
: p < CURSOR_START
? "boot"
: p < BROWSE_START
? "cursor-in"
: p < BROWSE_END
? "browse"
: p < CLOSE_START
? "hold"
: "close";
const slot = between(p, BROWSE_START, BROWSE_END) * count;
const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
const local = slot - destination;
const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
const travel = between(local, 0, ARRIVAL);
const cursorTab = moving
? destination - 1 + travel * travel * (3 - 2 * travel)
: destination;
const entry = between(p, CURSOR_START, BROWSE_START);
const frame = target ?? ({} as NotebookFrame);
frame.phase = phase;
frame.lidAngle =
OPEN_ANGLE *
between(p, OPEN_START, BOOT_START) *
(1 - between(p, CLOSE_LID_START, 1));
frame.deviceOpacity = between(p, 0, OPEN_START);
frame.screenOpacity =
between(p, BOOT_START, CURSOR_START) *
(1 - between(p, CLOSE_START, CLOSE_LID_START));
frame.activeTabIndex = activeTabIndex;
frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
frame.cursorX = count
? phase === "cursor-in"
? 0.5 + (0.5 / count - 0.5) * entry
: (cursorTab + 0.5) / count
: 0.5;
frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
frame.cursorClicking =
count > 0 &&
phase === "browse" &&
(destination === 0
? local < 0.07
: local >= ARRIVAL && local < ARRIVAL + 0.07);
return frame;
}
+2 -1
View File
@@ -8,11 +8,12 @@ import { siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/curriculo`;
export const metadata: Metadata = {
title: "Currículo de Evandro Fonseca Junior para Contratação",
title: { absolute: `Currículo de Evandro Fonseca Junior | ${siteName}` },
description:
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.",
alternates: { canonical: pageUrl },
openGraph: {
locale: "pt_BR",
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
description:
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
+510 -681
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -12,7 +12,7 @@ import "./globals.css";
const defaultTitle = "Sistemas sob medida para empresas | Evandro.dev.br";
const seoDescription =
"Desenvolvimento de sistemas web, aplicativos e integrações sob medida. Do entendimento do negócio à entrega em produção, com Evandro Fonseca Junior. Base em Santa Catarina e atendimento remoto.";
"Sistemas web, aplicativos e integrações sob medida, do negócio à produção, com Evandro Fonseca Junior. Base em Santa Catarina, atendimento remoto.";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
+16 -1
View File
@@ -1,10 +1,25 @@
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import CinematicHome from "./components/cinematic/CinematicHome";
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
function yearsOfExperience() {
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
return match ? Number(match[1]) : undefined;
}
export default async function Home() {
const projects = await loadFeaturedProjects();
return (
<CinematicHome
projects={await loadFeaturedProjects()}
heroStats={{
years: yearsOfExperience(),
projects: projects.length,
demos: projects.filter(
(project) =>
project.demoUrl && project.demoAvailability !== "unavailable",
).length,
}}
projects={projects}
experiences={portfolio.experiences}
skillCategories={portfolio.skills.technical}
/>
+1
View File
@@ -12,6 +12,7 @@ export const metadata: Metadata = {
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.",
alternates: { canonical: pageUrl },
openGraph: {
locale: "pt_BR",
title: `Privacidade e consentimento | ${siteName}`,
description:
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
+1
View File
@@ -45,6 +45,7 @@ export async function generateMetadata({
canonical: url,
},
openGraph: {
locale: "pt_BR",
title: service.seoTitle,
description: service.metaDescription,
url,
+1
View File
@@ -56,6 +56,7 @@ export const metadata: Metadata = {
canonical: pageUrl,
},
openGraph: {
locale: "pt_BR",
title: pageTitle,
description: pageDescription,
url: pageUrl,
+6
View File
@@ -0,0 +1,6 @@
import { ViewTransition } from "react";
/** O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css). */
export default function Template({ children }: { children: React.ReactNode }) {
return <ViewTransition name="page">{children}</ViewTransition>;
}
+98
View File
@@ -0,0 +1,98 @@
# Prompts de logo para os projetos do portfólio
Um prompt por projeto listado no site (`public/portfolio-data.json`). Cada um parte do que o projeto realmente é e das cores que ele já usa nas capturas de tela do repositório (`public/assets/prints/`). Quando não existe captura pública (Twins Lab, Reddit Posts Explorer, MinGW GCC Installer), a paleta abaixo é **proposta**, não extraída.
## Antes de gerar
- **Marcas de clientes.** SEV Moda Íntima, SC Plus e AS Serviços têm identidade própria (a SEV e a AS Serviços já têm logo). Os prompts abaixo geram um **selo de portfólio** (miniatura do projeto nos cards e no favicon), não uma nova marca para o cliente. Se o cliente autorizar, use o logo oficial dele.
- **Texto em imagem.** Geradores erram letras. Todos os prompts pedem marca sem palavras (só monograma quando indicado). O nome do projeto entra depois, como texto no site.
- **Formato.** Gere 1:1, em 4 variações por prompt, escolha uma e vetorize (SVG). Exporte `512 px`, `192 px` e `32 px` para testar legibilidade.
- **Onde salvar.** `public/assets/projects/<id>.svg` (e `.png`), com o `id` do projeto: `sev-modaintima`, `nutriscan`, `twins-lab`, `reddit-explorer`, `sc-plus`, `gcc-installer`, `as-servicos`.
## Bloco de estilo comum (cole no início de cada prompt)
```
Minimal flat vector logo mark, geometric, strong silhouette, 2 to 3 flat colors, no gradients, no shadows, no 3D, no bevel, centered on a square 1:1 canvas with 12% padding, solid background, must stay recognizable at 32x32 pixels, clean even stroke weight, no text.
```
## Prompt negativo comum
```
text, words, letters (except the monogram when requested), photo, realistic, 3D render, drop shadow, glow, bevel, gradient mesh, watermark, mockup, busy details, thin hairlines, multiple logos, border frame, QR code
```
---
## 1. SEV Moda Íntima (`sev-modaintima`)
Loja virtual de moda íntima com vitrine cinematográfica e pedido pelo WhatsApp. Identidade observada: vinho/vermelho profundo, creme, serifa elegante de alto contraste.
```
[bloco de estilo comum]
Logo mark for a premium lingerie e-commerce brand. A monogram of the letters "S" and "V" in an elegant high-contrast serif, the two letters interlocked inside a soft arch that echoes the curve of lace trim. Palette: deep wine red #9B1C31 and warm cream #F6EFE6 on a #1A0F12 near-black background. Refined, feminine, confident, not cartoonish.
```
Variação clara: mesmo desenho em vinho sobre fundo creme.
## 2. NutriScan (`nutriscan`)
Consulta de produtos por nome ou código de barras, com cache JSON e Open Food Facts. Identidade observada: fundo creme, verde-floresta, serifa com itálico.
```
[bloco de estilo comum]
Logo mark for a food-data lookup app. A single leaf whose central vein is replaced by a short row of vertical barcode bars, framed by four small corner brackets like a scanner viewfinder. Palette: forest green #2E7D4F on warm cream #F7F3EA, dark ink #1F1B16 for the bars only. Friendly, trustworthy, calm, editorial.
```
## 3. Twins Lab (`twins-lab`)
Protótipo de gêmeo digital industrial: simulador de reator e transmissão de eventos pelo servidor (SSE) a cada dois segundos. Paleta **proposta**.
```
[bloco de estilo comum]
Logo mark for an industrial digital-twin prototype. Two identical cylindrical reactor vessels side by side, the right one drawn as a clean outline (the digital twin) and the left one solid, joined by a short pulse line (a small heartbeat zigzag) between them. Palette: graphite #0F1720 background, solid vessel in off-white #E8EEF2, twin outline and pulse in electric cyan #22D3EE. Technical, precise, calm.
```
## 4. Reddit Posts Explorer (`reddit-explorer`)
Coleta posts do r/artificial, grava no MongoDB e filtra por período, upvotes ou comentários. Paleta **proposta**; evite o mascote e o laranja exato do Reddit (marca registrada).
```
[bloco de estilo comum]
Logo mark for a tool that explores and filters social-media posts about artificial intelligence. Three overlapping rounded speech bubbles arranged like a stack, with a small filter funnel notch cut into the front bubble. Palette: deep indigo #2B2A6E and off-white #F4F2ED, one warm accent #FF7A45 on the funnel only. Do not use any alien or mascot character and do not imitate the Reddit logo. Smart, clear, neutral.
```
## 5. SC Plus (`sc-plus`)
Site institucional de educação, ciência e tecnologia em Garuva-SC (identidade observada: fundo escuro, verde vivo, sans geométrica).
```
[bloco de estilo comum]
Logo mark for a community education, science and technology organization focused on sustainability. A plus sign whose upper arm grows into a small sprout with two leaves, and whose center is a single round circuit node. Palette: vivid green #22C55E and white #FFFFFF on a near-black #0A0A0A background. Optimistic, modern, community-oriented.
```
## 6. MinGW GCC Installer (`gcc-installer`)
Instalador gráfico de MinGW-w64/GCC para Windows: lista builds, baixa, instala em `C:\mingw64` e ajusta o PATH. Paleta **proposta**; sem logotipo do Windows.
```
[bloco de estilo comum]
Logo mark for a developer-tool installer. An open package box with a downward arrow entering it, and a terminal prompt ">_" cut out of the front face of the box. Palette: slate #1E293B background, box in sky blue #38BDF8, arrow and prompt in white #FFFFFF. Do not use the Windows logo or any four-pane window shape. Practical, engineered, no-nonsense.
```
## 7. AS Serviços (`as-servicos`)
Site institucional em Next.js com cena 3D gerada em código (casa wireframe, instalação elétrica e placa-mãe; três disciplinas). Identidade observada: azul forte, camadas de prancha em roxo, azul e cobre.
```
[bloco de estilo comum]
Logo mark for a multidisciplinary engineering firm (building, electrical, computing). Three isometric layers stacked like architectural drawing sheets, drawn only with thin even outlines and small round node dots at the corners; the stack reads as a simple geometric peak. Palette: strong blue #0B5FD8 for the top layer, violet #7C4DFF for the middle layer, copper #B26A2B for the bottom layer, on white #FFFFFF. Blueprint feel, clean, technical, no letters.
```
---
## Checklist depois de gerar
- [ ] Conferir em 32 px e em fundo claro e escuro.
- [ ] Vetorizar e limpar nós (SVG).
- [ ] Salvar em `public/assets/projects/<id>.svg` e `.png`.
- [ ] Para marcas de clientes, confirmar autorização de uso antes de publicar.
+2
View File
@@ -4,6 +4,8 @@
Este é um runbook declarativo para um operador autorizado. Ele descreve evidências que deverão ser coletadas em contas e painéis externos; não registra execução, acesso, configuração, medição ou resultado externo. Search Console, Bing Webmaster Tools, Cloudflare, CrUX e RUM são próximas ações externas, não evidência do repositório.
Medições executadas e o auditor repetível estão em `docs/seo-geo-validation.md`.
## Regras de operação
- Executar cada ação somente com autorização e na propriedade correta.
+75
View File
@@ -0,0 +1,75 @@
# SEO, AEO e GEO: medição antes e depois (2026-10-05)
Este documento registra o que foi **medido**, com ferramenta e comando, e o que ainda **depende de conta externa**. Ele complementa `docs/seo-geo-operations.md` (runbook de Search Console, Bing, Cloudflare e CrUX, ainda não executado) e respeita as exclusões dele: sem `llms.txt`, sem `FAQPage`/`ProfessionalService`/`LocalBusiness`, sem páginas por cidade, sem `keywords`, sem datas ou métricas inventadas.
## Como repetir
```bash
# 1) auditoria (HTML sem JavaScript, como um robô vê); funciona em produção ou localhost
node scripts/seo-audit.mjs --base https://evandro.dev.br --out audit-depois.json
# 2) comparar duas execuções (antes.json vs depois.json); sai com código 2 se algo piorou
node scripts/seo-compare.mjs audit-antes.json audit-depois.json
```
A auditoria lê o `sitemap.xml` e confere, página a página: status, compressão, TTFB, `lang`, `title` e `description` (tamanho e unicidade), canônica própria, indexabilidade, um `h1` e hierarquia, `alt` das imagens, links internos e externos, Open Graph e Twitter Card (e se a imagem social abre), JSON-LD (válido, referências `@id` resolvem, sem os tipos excluídos pela política), resposta direta cedo no HTML, acesso de 8 robôs de busca e de IA (Googlebot, bingbot, OAI-SearchBot, GPTBot, ChatGPT-User, Claude-SearchBot, ClaudeBot, PerplexityBot), e-mail visível para robôs, links do GitHub e links internos quebrados. Cada verificação é passa/falha com a evidência; não existe "nota" inventada.
## Resultado: `main` contra a branch (build de produção local, 17 URLs)
| Grupo | `main` | branch |
|---|---:|---:|
| técnico | 57/57 | 57/57 |
| on-page | 204/221 | **213/221** |
| social (Open Graph e Twitter) | 35/51 | **51/51** |
| AEO/GEO | 54/60 | **56/60** |
| dados estruturados | 84/84 | 84/84 |
27 verificações corrigidas e 0 pioradas. O que mudou: `og:locale` em todas as rotas (só a home tinha), títulos e descrições mais curtos, e conteúdo textual novo em 2 cases.
Lighthouse (categorias SEO, acessibilidade e boas práticas, mobile): 100 / 97 / 100 nas duas versões. **O Lighthouse não distingue esse tipo de melhoria**, por isso a auditoria acima é a medida principal.
Validador independente: `structured-data-testing-tool` (npm), preset de redes sociais. Facebook/Open Graph 100% e Twitter 71–85%; o aviso restante é `twitter:site` ausente (opcional). A ferramenta **não** interpreta JSON-LD dentro de `@graph` (reporta "WebSite não encontrado" por limitação dela, não do site); para o JSON-LD, use os dois validadores manuais abaixo.
## O que foi alterado no código
- `openGraph.locale: "pt_BR"` nas 6 rotas que redefinem `openGraph`.
- Titles: casos passam de "Case de Engenharia de Software" para "Case de Engenharia"; `/curriculo`, Tauri e Engenharia de Software ficam mais curtos. Descrição da home de 193 para 146 caracteres e a de AS Serviços de 184 para 136.
- `Person` (JSON-LD) ganha `jobTitle` e `knowsAbout`, ambos já visíveis no site.
- Reddit Posts Explorer e MinGW GCC Installer ganham resposta, desafio, implementação, limites e fontes fixadas em commit (repositórios públicos); os textos anteriores diziam o que o código não sustenta (SSG, "rate limiting" por cache, busca em tempo real, download assíncrono, monitoramento).
- SC Plus: removida a alegação "otimizada para Core Web Vitals / LCP < 2,5 s". Medição local (Lighthouse mobile): LCP 3,7 s, desempenho 89.
- Hero e páginas: conteúdo visível já no HTML (LCP da home no Lighthouse local: 4,0 s para 3,5 s).
## Achados que o código não resolve (ações suas)
Ordenados pelo efeito provável na descoberta:
1. **Cloudflare ofusca o e-mail para robôs.** Confirmado em produção: o HTML servido ao Googlebot tem `/cdn-cgi/l/email-protection#...` e o texto "[email protected]", sem nenhum `mailto:`. Buscadores e respostas de IA não veem o contato. Segundo a [documentação da Cloudflare](https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/), desative em *Security > Settings > Email Address Obfuscation* (ou envolva um endereço com `<!--email_off-->`). A verificação `email-visivel-para-robos` da auditoria passa a ficar verde depois.
2. **Resultados de busca desatualizados.** Uma busca por "evandro.dev.br" ainda mostra o título e a descrição antigos ("Engenharia de Software, Next.js, Tauri, Rust e Consultoria Tecnica"). Peça nova indexação no Search Console (Inspeção de URL > Solicitar indexação) e envie a URL no Bing Webmaster Tools.
3. **Dois repositórios com "Ver código" quebrado.** `evandrodevbr/twins-lab` e `evandrodevbr/scplus` respondem 404 para visitantes (são privados), e o Twins Lab traz o selo "Código público". Torne os repositórios públicos ou remova o link e o selo. A auditoria acusa isso (`links-github-acessiveis`).
4. **Posicionamento e URLs diferentes entre perfis.** O site se apresenta como "sistemas sob medida para empresas"; o perfil do GitHub diz "Backend Software Engineer | Node.js, NestJS, TypeScript, Python & PostgreSQL", com empresa "Apolo Energia", e o README do perfil se declara aberto a vagas de backend (os "cinco anos" coincidem com o site). Respostas de IA misturam essas descrições. Escolha a frase central e repita-a nos perfis. O LinkedIn também aparece com duas URLs no site (`/in/evandro-fonseca-junior-0969aa1b0/` na hero e no JSON-LD, `/in/evandrodevbr/` no rodapé): use a mesma.
5. **Homônimos.** Há outras pessoas com nome parecido (por exemplo "Evandro Junior" no LinkedIn e "Evandro Fonseca Junior" em outros sites). `sameAs` e a repetição do mesmo texto de apresentação nos perfis ajudam a desambiguar.
6. **SC Plus.** O site no ar se apresenta como "SCPLUS — Educação, Ciência e Tecnologia em Garuva-SC", e o portfólio diz "SC Plus Aquicultura". Confirme o nome correto.
## Ferramentas que precisam da sua conta (não executadas)
| Ferramenta | Para quê | O que observar |
|---|---|---|
| [Google Search Console](https://search.google.com/search-console) | cobertura, canônica escolhida, consultas, cliques | as 17 URLs do sitemap; a canônica que o Google escolheu |
| [Bing Webmaster Tools](https://www.bing.com/webmasters) | indexação e relatório de IA (quando disponível) | AI Performance, se a conta tiver |
| [Rich Results Test](https://search.google.com/test/rich-results) | JSON-LD como o Google lê | `Person`, `WebSite`, `BreadcrumbList` sem erros |
| [Schema Markup Validator](https://validator.schema.org/) | JSON-LD contra schema.org | sem erros em `@graph` |
| [CrUX](https://developer.chrome.com/docs/crux) / PageSpeed Insights | desempenho de campo (LCP, INP, CLS) | só aparece com tráfego suficiente; sem dado de campo, não substitua por nota de laboratório |
## Fontes conferidas
- Google, [AI features and your website](https://developers.google.com/search/docs/appearance/ai-features): não há marcação nem arquivo especial para aparecer em AI Overviews/AI Mode; vale o SEO padrão (conteúdo em texto, links internos, robots.txt liberando, dados estruturados coerentes com o texto).
- Google, [FAQPage](https://developers.google.com/search/docs/appearance/structured-data/faqpage): resultado enriquecido só para sites governamentais e de saúde conhecidos. Por isso `FAQPage` continua excluído.
- OpenAI, [crawlers](https://developers.openai.com/api/docs/bots): `OAI-SearchBot` precisa estar liberado para aparecer nas buscas do ChatGPT; `GPTBot` é de treinamento; `ChatGPT-User` é iniciado pelo usuário. O `robots.txt` do site já libera todos (`User-agent: *`, `Allow: /`) e a auditoria confirma HTTP 200 para os 8 robôs.
- Anthropic, [crawlers](https://privacy.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler): `ClaudeBot` (treinamento), `Claude-User` e `Claude-SearchBot` (busca), cada um controlável no `robots.txt`.
- `llms.txt`: o Google informou que nenhum sistema de IA o usa hoje e que não planeja suportá-lo (declarações de John Mueller e Gary Illyes reportadas pela imprensa especializada). Continua excluído.
## Limites desta medição
- A auditoria vê o HTML entregue sem JavaScript; ela não prevê posição em buscas nem citações em respostas de IA. Esses resultados só aparecem com dados de plataforma (Search Console, Bing, relatórios de IA) ao longo de semanas.
- A busca usada para ver como o site aparece hoje é uma única consulta pontual; não é série histórica.
- Limiares como 30–65 caracteres no título e 70–165 na descrição são orientações de exibição, não regras de ranqueamento.
+21
View File
@@ -86,6 +86,27 @@ describe("factual portfolio records", () => {
/produç|clientes?|leads?|ranking|performance|resultado comercial|anime\.js|shadcn/i,
);
});
it("keeps Reddit Posts Explorer and MinGW GCC Installer within what their public repositories support", () => {
const reddit = serializeProject("reddit-explorer");
const gcc = serializeProject("gcc-installer");
expect(reddit).toContain("MongoDB");
expect(reddit).not.toMatch(
/SSG|rate limit|cache|tempo real|real-time|busca em|otimiza|responsiv/i,
);
expect(gcc).toContain("mingw64");
expect(gcc).not.toMatch(
/assíncron|monitoramento|drastic|Watchdog|dependências|intuitiv/i,
);
});
it("does not claim measured performance for SC Plus without a stored measurement", () => {
// Lighthouse mobile local: LCP 3,7 s em 2026-10-05; a alegação anterior (LCP < 2,5 s) não se sustentava
expect(serializeProject("sc-plus")).not.toMatch(
/LCP|Core Web Vitals|otimizad|velocidade|rápid|Performance/i,
);
});
it("does not publish the unavailable Twins Lab demo", () => {
expect(
portfolio.projects.find((item) => item.id === "twins-lab")?.demoUrl,
+12 -1
View File
@@ -37,7 +37,17 @@ it("keeps the root graph limited to verified website and person fields", () => {
["@id", "@type", "inLanguage", "name", "publisher", "url"].sort(),
);
expect(Object.keys(person).sort()).toEqual(
["@id", "@type", "homeLocation", "image", "name", "sameAs", "url"].sort(),
[
"@id",
"@type",
"homeLocation",
"image",
"jobTitle",
"knowsAbout",
"name",
"sameAs",
"url",
].sort(),
);
expect(website).toMatchObject({
"@type": "WebSite",
@@ -49,6 +59,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
"@type": "Person",
"@id": SEO_IDS.person,
image: `${siteUrl}/edev.jpg`,
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" },
sameAs: [
"https://github.com/evandrodevbr",
+12
View File
@@ -24,6 +24,18 @@ export function buildPersonJsonLd() {
name: "Evandro Fonseca Junior",
url: siteUrl,
image: `${siteUrl}/edev.jpg`,
// cargo e áreas vêm de public/portfolio-data.json e estão visíveis em /curriculo e na seção de skills
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
knowsAbout: [
"Next.js",
"React",
"TypeScript",
"Node.js",
"Python",
"IoT",
"Computação em nuvem",
"Inteligência artificial",
],
homeLocation: {
"@type": "Place",
name: "Garuva, Santa Catarina, Brasil",
+26 -2
View File
@@ -1,6 +1,12 @@
import { caseStudies, getRelatedServices, servicePages } from "./site-content";
const detailedIds = ["as-servicos", "nutriscan", "twins-lab"] as const;
const detailedIds = [
"as-servicos",
"gcc-installer",
"nutriscan",
"reddit-explorer",
"twins-lab",
] as const;
const immutableGitHubBlobUrl =
/^https:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[0-9a-f]{40}\/.+$/;
@@ -11,7 +17,7 @@ function getDetail(id: (typeof detailedIds)[number]) {
return detail;
}
it("limits detailed content to the three evidenced cases", () => {
it("limits detailed content to the evidenced cases (public repositories pinned to a commit)", () => {
expect(
caseStudies
.filter(({ detail }) => detail !== undefined)
@@ -64,6 +70,13 @@ it("requires sources for factual statements and resolves every source ID", () =>
expect(getDetail("nutriscan").boundaries[0]?.sourceIds).toContain(
"nutri-readme",
);
// reddit-explorer e gcc-installer: o 1º limite vem da documentação do repositório; o 2º é o escopo editorial (sem fonte)
for (const id of ["reddit-explorer", "gcc-installer"] as const) {
const { boundaries } = getDetail(id);
expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0);
expect(boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0)).toBe(true);
}
});
it("derives the exact related service set from the existing relationship", () => {
@@ -96,3 +109,14 @@ it("does not publish the unavailable Twins Lab demo", () => {
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
).toBeUndefined();
});
it("keeps AS Serviços as a project (code only) and states the author's participation", () => {
const asServicos = caseStudies.find(({ id }) => id === "as-servicos");
expect(asServicos?.demoUrl).toBeNull();
expect(asServicos?.githubUrl).toBe(
"https://github.com/evandrodevbr/as-servicos",
);
expect(asServicos?.role).toMatch(/primeira landing page/i);
expect(asServicos?.role).toMatch(/animações 3D/i);
expect(asServicos?.role).toMatch(/painel para controle de projetos/i);
});
+131 -17
View File
@@ -75,6 +75,8 @@ export interface CaseStudyContent {
stack: string[];
demoUrl?: string | null;
githubUrl?: string | null;
/** Papel do autor quando o projeto não é só dele (texto editorial, sem fonte pública). */
role?: string;
detail?: CaseStudyDetail;
}
@@ -144,7 +146,7 @@ export const servicePages: ServicePageContent[] = [
slug: "engenharia-de-software",
navLabel: "Sistemas web e aplicativos mobile",
eyebrow: "Engenharia de software",
seoTitle: "Desenvolvimento de Sistemas Web e Aplicativos Mobile",
seoTitle: "Sistemas Web e Aplicativos Mobile sob Medida",
metaDescription:
"Sistemas web e aplicativos mobile sob medida, integração entre ferramentas e evolução de software existente. Escopo, plataformas e entregas definidos por projeto.",
headline: "Sistemas e apps para o seu trabalho.",
@@ -266,7 +268,7 @@ export const servicePages: ServicePageContent[] = [
slug: "tauri-rust",
navLabel: "Aplicativos desktop para Windows e Linux",
eyebrow: "Aplicativos desktop",
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
seoTitle: "Aplicativos Desktop com Tauri e Rust",
metaDescription:
"Desenvolvimento de aplicativos desktop para Windows e Linux com Tauri e Rust. Ferramentas locais, arquivos e integrações com compatibilidade definida por projeto.",
headline: "Aplicativos para Windows e Linux.",
@@ -618,22 +620,83 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "reddit-explorer",
title: "Reddit Posts Explorer",
subtitle: "Aplicação orientada a dados com API, cache e busca",
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
summary:
"Projeto em Astro e MongoDB para explorar conteúdo com filtros, ordenação e cache, mantendo boa experiência em consultas frequentes.",
"Aplicação Astro SSR que coleta os posts em alta do r/artificial, grava no MongoDB sem duplicar e filtra por período, upvotes ou comentários.",
impact:
"Mostra capacidade de desenhar consumo de API, camada de persistência e experiência de busca sem sobrecarregar a aplicação.",
stack: ["Astro.js", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
"Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
stack: ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
githubUrl: "https://github.com/evandrodevbr/reddit-search",
detail: {
answer: {
text: "Aplicação Astro em modo SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
sourceIds: ["rd-readme", "rd-service", "rd-astro"],
},
challenge: {
text: "Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
sourceIds: ["rd-service", "rd-filter"],
},
implementation: [
{
text: "O serviço consulta r/artificial/hot.json com User-Agent identificado e limite de 15 segundos, e grava os posts em lote com upsert pelo id; rodar a coleta de novo atualiza os existentes.",
sourceIds: ["rd-service"],
},
{
text: "O endpoint de filtro valida as datas e o campo de ordenação (ups ou num_comments) e responde 400 quando faltam parâmetros ou eles são inválidos.",
sourceIds: ["rd-filter"],
},
{
text: "O Astro roda com output server e o adapter Node em modo standalone.",
sourceIds: ["rd-astro", "rd-package"],
},
],
boundaries: [
{
text: "O subreddit é fixo, e o projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
sourceIds: ["rd-readme"],
},
{
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, volume de dados ou resultado comercial.",
sourceIds: [],
},
],
sources: [
{
id: "rd-readme",
label: "README do projeto",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/README.md",
},
{
id: "rd-service",
label: "Serviço de coleta e consulta",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/lib/services/redditService.ts",
},
{
id: "rd-filter",
label: "Endpoint de filtro",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/pages/api/posts/filter.ts",
},
{
id: "rd-astro",
label: "Configuração do Astro",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/astro.config.mjs",
},
{
id: "rd-package",
label: "Manifesto do projeto",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/package.json",
},
],
},
},
{
id: "sc-plus",
title: "SC Plus Aquicultura",
subtitle: "Site institucional em Next.js com foco em performance e SEO",
subtitle: "Landing page institucional em Next.js 15",
summary:
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura técnica de indexação.",
"Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
impact:
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO técnico trabalham juntos.",
"Dar à instituição uma página própria, com base técnica para ser encontrada em buscadores.",
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
demoUrl: "https://scplus.evandro.dev.br",
githubUrl: "https://github.com/evandrodevbr/scplus",
@@ -641,29 +704,80 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "gcc-installer",
title: "MinGW GCC Installer",
subtitle:
"Ferramenta desktop para automação de ambiente de desenvolvimento",
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
summary:
"Aplicação em Python para simplificar setup local no Windows, com download assíncrono, checagem de compatibilidade e integração com PATH.",
"Aplicativo Python com interface tkinter que lista as builds do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e adiciona o GCC ao PATH do usuário.",
impact:
"Mostra familiaridade com software desktop, integração com sistema operacional e automação de setup técnico.",
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
"Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
stack: ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
detail: {
answer: {
text: "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
sourceIds: ["gcc-readme", "gcc-main"],
},
challenge: {
text: "Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
sourceIds: ["gcc-readme"],
},
implementation: [
{
text: "A lista de versões vem da API de releases do repositório niXman/mingw-builds-binaries, e as builds com ucrt e thread model posix são destacadas na tabela.",
sourceIds: ["gcc-main"],
},
{
text: "A instalação extrai o pacote com o 7-Zip (caminho padrão fixo) em uma pasta temporária, move o resultado para C:\\mingw64 e renomeia mingw32-make.exe para make.exe.",
sourceIds: ["gcc-main"],
},
{
text: "Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE; fora do Windows o programa encerra com um aviso.",
sourceIds: ["gcc-main"],
},
],
boundaries: [
{
text: "Funciona só no Windows, o caminho do 7-Zip é fixo e o fluxo da interface não foi exercitado de ponta a ponta no Windows nesta revisão; os 23 testes cobrem a lógica independente de plataforma, e não há CI.",
sourceIds: ["gcc-readme", "gcc-tests"],
},
{
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, número de usuários ou resultado comercial.",
sourceIds: [],
},
],
sources: [
{
id: "gcc-readme",
label: "README do projeto",
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/README.md",
},
{
id: "gcc-main",
label: "Aplicativo (main.py)",
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/main.py",
},
{
id: "gcc-tests",
label: "Testes do núcleo",
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/tests/test_core.py",
},
],
},
},
{
id: "as-servicos",
title: "AS Serviços",
subtitle: "Site institucional com cena 3D procedural",
summary:
"Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
"Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
impact:
"Representar engenharia física e software na mesma narrativa visual sem depender de um modelo 3D pronto.",
stack: ["Next.js 16", "Three.js", "TypeScript"],
demoUrl: "https://asservicos.com",
demoUrl: null,
githubUrl: "https://github.com/evandrodevbr/as-servicos",
role: "Desenvolvimento da primeira landing page do site, com animações 3D, e de um painel para controle de projetos.",
detail: {
answer: {
text: "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
text: "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
sourceIds: ["as-readme", "as-package", "as-engine"],
},
challenge: {
+1
View File
@@ -6,4 +6,5 @@ export const NAVIGATION_ITEMS = [
{ href: "/cases", label: "Projetos" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/#experience", label: "Experiência" },
] as const;
+1
View File
@@ -29,6 +29,7 @@
"@react-three/fiber": "9.5.0",
"clsx": "2.1.1",
"gsap": "3.13.0",
"lenis": "^1.3.26",
"lucide-react": "0.542.0",
"motion": "12.23.12",
"next": "16.3.3",
+21
View File
@@ -20,6 +20,9 @@ importers:
gsap:
specifier: 3.13.0
version: 3.13.0
lenis:
specifier: ^1.3.26
version: 1.3.26([email protected])
lucide-react:
specifier: 0.542.0
version: 0.542.0([email protected])
@@ -1886,6 +1889,20 @@ packages:
engines: {node: '>=6'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
vue: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
react:
optional: true
vue:
optional: true
[email protected]:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -4762,6 +4779,10 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
react: 19.1.0
[email protected]: {}
[email protected]:
+31 -55
View File
@@ -152,31 +152,23 @@
{
"id": "reddit-explorer",
"title": "Reddit Posts Explorer",
"subtitle": "Análise de Conteúdo IA",
"description": "Aplicação Astro.js com arquitetura SSG otimizada para consumo de Reddit API. Resolve problema de rate limiting através de cache estratégico em MongoDB com Mongoose. Sistema de filtragem por data e ordenação, API REST própria e busca em tempo real com otimização de queries.",
"longDescription": "Aplicação web que consome a API do Reddit para buscar, filtrar e analisar posts relacionados à Inteligência Artificial, oferecendo interface intuitiva para descoberta de conteúdo relevante.",
"technologies": [
"Astro.js",
"MongoDB",
"Mongoose",
"TypeScript",
"TailwindCSS",
"Reddit API"
],
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
"technologies": ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
"features": [
"Integração com Reddit API",
"Sistema de cache com MongoDB",
"Filtragem por data e ordenação",
"Interface responsiva",
"API REST própria",
"Busca em tempo real"
"Coleta dos posts em alta do r/artificial",
"Upsert por id no MongoDB",
"Filtro por período",
"Ordenação por upvotes ou comentários",
"API própria com validação de parâmetros"
],
"category": "api",
"level": "pleno",
"status": "Concluído",
"demoUrl": null,
"githubUrl": "https://github.com/evandrodevbr/reddit-search",
"badges": ["API Integration", "MongoDB", "Real-time"],
"badges": ["API Integration", "MongoDB", "Código público"],
"featured": true,
"metrics": {
"complexity": 3,
@@ -186,28 +178,21 @@
{
"id": "sc-plus",
"title": "SC Plus Aquicultura",
"subtitle": "Landing Page Institucional",
"description": "Landing page Next.js 15 com React 19 e Tailwind CSS 4. Arquitetura otimizada para Core Web Vitals com LCP < 2.5s através de code splitting e server-side rendering. SEO técnico implementado com meta tags dinâmicas e structured data.",
"longDescription": "Site institucional responsivo desenvolvido com as mais recentes tecnologias web, otimizado para performance e SEO, apresentando os serviços e valores da empresa de aquicultura sustentável.",
"technologies": [
"Next.js 15",
"React 19",
"Tailwind CSS 4",
"TypeScript"
],
"subtitle": "Landing page institucional em Next.js 15",
"description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
"technologies": ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
"features": [
"Design moderno e responsivo",
"Performance otimizada",
"SEO otimizado",
"Interface intuitiva",
"Carregamento rápido"
"Landing page institucional",
"Metadados e URL canônica",
"Dados estruturados"
],
"category": "web",
"level": "pleno",
"status": "Concluído",
"demoUrl": "https://scplus.evandro.dev.br",
"githubUrl": "https://github.com/evandrodevbr/scplus",
"badges": ["Responsive", "SEO", "Modern Stack"],
"badges": ["Landing page", "Next.js 15"],
"featured": true,
"metrics": {
"complexity": 2,
@@ -217,32 +202,23 @@
{
"id": "gcc-installer",
"title": "MinGW GCC Installer",
"subtitle": "Ferramenta de Desenvolvimento",
"description": "Aplicação desktop Python com Tkinter para automação de setup de ambiente GCC no Windows. Resolve problema de configuração manual complexa através de download assíncrono com threading, verificação de compatibilidade via Win32 API e integração automática com PATH do sistema. Utiliza 7-Zip para extração e Watchdog para monitoramento.",
"longDescription": "Ferramenta automatizada que simplifica drasticamente o processo de configuração de ambiente de desenvolvimento GCC no Windows, incluindo verificação de compatibilidade, download automático e integração com o sistema.",
"technologies": [
"Python",
"Tkinter",
"Requests",
"Watchdog",
"Win32 API",
"7-Zip",
"Threading"
],
"subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows",
"description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
"longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Funciona só no Windows e o caminho do 7-Zip é fixo.",
"technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
"features": [
"Interface gráfica intuitiva",
"Download assíncrono com progresso",
"Verificação de compatibilidade",
"Integração automática com PATH",
"Monitoramento em tempo real",
"Instalação automática de dependências"
"Lista as builds publicadas do MinGW-w64",
"Destaca builds ucrt e posix",
"Baixa e instala em C:\\mingw64",
"Adiciona o toolchain ao PATH do usuário",
"Testes do núcleo (23)"
],
"category": "desktop",
"level": "pleno",
"status": "Concluído",
"demoUrl": null,
"githubUrl": "https://github.com/evandrodevbr/gcc-installer",
"badges": ["Desktop", "Automation", "System Integration"],
"badges": ["Desktop", "Windows", "Código público"],
"featured": true,
"metrics": {
"complexity": 4,
@@ -253,7 +229,7 @@
"id": "as-servicos",
"title": "AS Serviços",
"subtitle": "Site Institucional com Cena 3D Procedural",
"description": "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
"description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
"longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, reduced motion, economia de dados, conexão e capacidade do dispositivo.",
"technologies": ["Next.js 16", "TypeScript", "Three.js"],
"features": [
@@ -264,13 +240,13 @@
"category": "web",
"level": "senior",
"status": "Código público",
"demoUrl": "https://asservicos.com",
"demoUrl": null,
"githubUrl": "https://github.com/evandrodevbr/as-servicos",
"badges": ["Three.js", "WebGL", "Código público"],
"featured": true,
"metrics": {
"complexity": 5,
"teamSize": "Individual"
"teamSize": "Em equipe"
}
}
],
+8
View File
@@ -0,0 +1,8 @@
// O App Router usa a versão canary do React, que exporta <ViewTransition>; os tipos estáveis (@types/react 19.1)
// só trazem o nome provisório unstable_ViewTransition. Remover quando os tipos acompanharem.
import type { ExoticComponent, ViewTransitionProps } from "react";
import type {} from "react/experimental";
declare module "react" {
export const ViewTransition: ExoticComponent<ViewTransitionProps>;
}
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* Auditoria repetível de SEO / AEO / GEO, sem dependências (Node >= 20).
*
* node scripts/seo-audit.mjs --base http://localhost:3000 --out audit-antes.json
* node scripts/seo-compare.mjs audit-antes.json audit-depois.json
*
* Lê o sitemap, baixa cada URL como um robô (HTML sem JavaScript) e confere o que buscadores e sistemas de
* resposta/IA realmente enxergam. Não inventa nota: cada verificação é passa/falha com a evidência.
* O que depende de conta (Search Console, Bing, CrUX, citações em plataformas de IA) está em docs/seo-geo-operations.md.
*/
import { writeFileSync } from "node:fs";
const args = Object.fromEntries(
process.argv
.slice(2)
.map((a, i, all) => (a.startsWith("--") ? [a.slice(2), all[i + 1]] : null))
.filter(Boolean),
);
const BASE = (args.base ?? "https://evandro.dev.br").replace(/\/$/, "");
const SITE = "https://evandro.dev.br"; // origem canônica declarada no código
const UA = "Mozilla/5.0 (compatible; seo-audit/1.0)";
const AI_BOTS = [
["Googlebot", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"],
["bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"],
["OAI-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot"],
["GPTBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)"],
["ChatGPT-User", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)"],
["Claude-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; [email protected])"],
["ClaudeBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; [email protected])"],
["PerplexityBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"],
];
const local = (url) => url.replace(SITE, BASE); // permite auditar localhost com URLs canônicas de produção
const get = async (url, ua = UA, init = {}) => {
const t0 = performance.now();
const res = await fetch(url, { headers: { "user-agent": ua, "accept-encoding": "gzip, br" }, redirect: "manual", ...init });
const body = init.method === "HEAD" ? "" : await res.text();
return { res, body, ms: Math.round(performance.now() - t0) };
};
const attrs = (tag) => {
const out = {};
for (const m of tag.matchAll(/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[m[1].toLowerCase()] = m[2] ?? m[3] ?? "";
return out;
};
const decode = (s) => s.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim();
const strip = (html) =>
decode(
html
.replace(/<(script|style|noscript|template|svg)[\s\S]*?<\/\1>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " "),
);
const words = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
function parsePage(html) {
const metas = [...html.matchAll(/<meta\b[^>]*>/gi)].map((m) => attrs(m[0]));
const meta = (key, val = "name") => metas.find((m) => m[val]?.toLowerCase() === key)?.content;
const links = [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => attrs(m[0]));
const headings = [...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)].map((m) => ({ level: +m[1], text: strip(m[2]) }));
const imgs = [...html.matchAll(/<img\b[^>]*>/gi)].map((m) => attrs(m[0]));
const anchors = [...html.matchAll(/<a\b[^>]*>/gi)].map((m) => attrs(m[0]));
const ld = [...html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
const main = html.match(/<main\b[\s\S]*?<\/main>/i)?.[0] ?? html;
const firstH1 = main.search(/<h1\b/i);
const afterH1 = firstH1 >= 0 ? main.slice(firstH1) : main;
const firstP = [...afterH1.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => strip(m[1])).find((t) => words(t) >= 8);
return {
title: decode(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""),
description: meta("description"),
robots: meta("robots"),
viewport: meta("viewport"),
lang: html.match(/<html[^>]*\blang=["']([^"']+)/i)?.[1],
canonical: links.find((l) => l.rel === "canonical")?.href,
alternates: links.filter((l) => l.rel === "alternate" && l.hreflang).map((l) => l.hreflang),
og: Object.fromEntries(metas.filter((m) => m.property?.startsWith("og:")).map((m) => [m.property, m.content])),
tw: Object.fromEntries(metas.filter((m) => m.name?.startsWith("twitter:")).map((m) => [m.name, m.content])),
headings,
imgs,
anchors,
ld,
wordCount: words(strip(main)),
firstAnswerWords: firstP ? words(firstP) : 0,
};
}
function jsonLdFacts(blocks) {
const nodes = [];
const errors = [];
for (const raw of blocks) {
try {
const data = JSON.parse(raw);
const list = data["@graph"] ?? (Array.isArray(data) ? data : [data]);
nodes.push(...list);
} catch (e) {
errors.push(String(e.message).slice(0, 80));
}
}
const ids = new Set(nodes.map((n) => n["@id"]).filter(Boolean));
const types = nodes.flatMap((n) => [].concat(n["@type"] ?? []));
const dangling = [];
const walk = (o) => {
if (Array.isArray(o)) return o.forEach(walk);
if (o && typeof o === "object") {
const keys = Object.keys(o);
if (keys.length === 1 && keys[0] === "@id" && !ids.has(o["@id"]) && !String(o["@id"]).startsWith(SITE)) dangling.push(o["@id"]);
Object.values(o).forEach(walk);
}
};
walk(nodes);
return { nodes, ids, types, errors, dangling };
}
const checks = []; // {scope, id, group, ok, detail}
const add = (scope, group, id, ok, detail = "") => checks.push({ scope, group, id, ok: Boolean(ok), detail });
async function auditUrl(url, siteTitles, siteDescs) {
const target = local(url);
const { res, body, ms } = await get(target);
const scope = url.replace(SITE, "") || "/";
add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`);
if (res.status !== 200) return null;
add(scope, "técnico", "html-comprimido", /br|gzip/.test(res.headers.get("content-encoding") ?? "") || BASE.startsWith("http://localhost"), res.headers.get("content-encoding") ?? "sem compressão");
add(scope, "técnico", "ttfb-menor-800ms", ms < 800, `${ms} ms (HTML completo)`);
const p = parsePage(body);
add(scope, "on-page", "lang-pt-BR", p.lang?.toLowerCase() === "pt-br", p.lang ?? "ausente");
add(scope, "on-page", "viewport", Boolean(p.viewport));
add(scope, "on-page", "title-30-65", p.title.length >= 30 && p.title.length <= 65, `${p.title.length} car.: ${p.title}`);
add(scope, "on-page", "title-unico", !siteTitles.has(p.title), p.title);
siteTitles.add(p.title);
add(scope, "on-page", "description-70-165", !!p.description && p.description.length >= 70 && p.description.length <= 165, `${p.description?.length ?? 0} car.`);
add(scope, "on-page", "description-unica", !!p.description && !siteDescs.has(p.description));
if (p.description) siteDescs.add(p.description);
add(scope, "on-page", "canonical-propria", p.canonical === url || p.canonical === `${url}/`, p.canonical ?? "ausente");
add(scope, "on-page", "indexavel", !/noindex/i.test(p.robots ?? "") && !/noindex/i.test(res.headers.get("x-robots-tag") ?? ""));
const h1 = p.headings.filter((h) => h.level === 1);
add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`);
let skipped = false;
p.headings.reduce((prev, h) => {
if (h.level - prev > 1) skipped = true;
return h.level;
}, 0);
add(scope, "on-page", "hierarquia-de-titulos", !skipped);
add(scope, "on-page", "imagens-com-alt", p.imgs.every((i) => i.alt !== undefined), `${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`);
const internal = p.anchors.filter((a) => a.href && (a.href.startsWith("/") || a.href.startsWith(SITE)) && !a.href.startsWith("//"));
add(scope, "on-page", "links-internos-5+", new Set(internal.map((a) => a.href.split("#")[0])).size >= 5, `${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`);
const external = p.anchors.filter((a) => a.target === "_blank");
// noreferrer também implica noopener
add(scope, "on-page", "externos-com-noopener", external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")), `${external.length} links _blank; sem rel: ${external.filter((a) => !/noopener|noreferrer/.test(a.rel ?? "")).map((a) => a.href).slice(0, 2).join(" ")}`);
const og = p.og, tw = p.tw;
const missingOg = ["og:title", "og:description", "og:url", "og:image", "og:type", "og:site_name", "og:locale"].filter((k) => !og[k]);
add(scope, "social", "og-completo", missingOg.length === 0, missingOg.length ? `faltam ${missingOg.join(",")}` : "");
add(scope, "social", "twitter-card", tw["twitter:card"] === "summary_large_image" && !!tw["twitter:title"] && !!tw["twitter:image"]);
if (og["og:image"]) {
const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(() => null);
add(scope, "social", "og-image-acessivel", img?.res.status === 200 && /^image\//.test(img.res.headers.get("content-type") ?? ""), `${img?.res.status} ${img?.res.headers.get("content-type")}`);
}
const ld = jsonLdFacts(p.ld);
add(scope, "dados-estruturados", "json-ld-valido", p.ld.length > 0 && ld.errors.length === 0, ld.errors.join("; ") || `${p.ld.length} blocos`);
add(scope, "dados-estruturados", "referencias-resolvem", ld.dangling.length === 0, ld.dangling.join(", "));
add(scope, "dados-estruturados", "sem-faqpage-nem-localbusiness", !ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)), ld.types.join(","));
if (scope !== "/") add(scope, "dados-estruturados", "breadcrumb", ld.types.includes("BreadcrumbList"));
add(scope, "dados-estruturados", "person-com-sameas", !ld.types.includes("Person") || ld.nodes.some((n) => n["@type"] === "Person" && Array.isArray(n.sameAs) && n.sameAs.length >= 2));
add(scope, "aeo-geo", "conteudo-no-html-sem-js", p.wordCount >= 250, `${p.wordCount} palavras no <main> do HTML`);
add(scope, "aeo-geo", "resposta-direta-cedo", p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70, `${p.firstAnswerWords} palavras no 1º parágrafo após o H1`);
const names = new Set(ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name));
add(scope, "aeo-geo", "entidade-autor-consistente", names.size <= 1 && (scope === "/" ? names.size === 1 : true), [...names].join(","));
return { scope, p, ld };
}
async function main() {
const sm = await get(`${BASE}/sitemap.xml`);
const urls = [...sm.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
add("site", "técnico", "sitemap-200", sm.res.status === 200 && urls.length > 0, `${urls.length} URLs`);
const robots = await get(`${BASE}/robots.txt`);
add("site", "técnico", "robots-com-sitemap", /sitemap:\s*https?:\/\//i.test(robots.body));
add("site", "técnico", "robots-nao-bloqueia-tudo", !/disallow:\s*\/\s*$/im.test(robots.body));
const home = await get(`${BASE}/`);
add("site", "técnico", "http-para-https-ou-local", true);
for (const [name, ua] of AI_BOTS) {
const r = await get(`${BASE}/`, ua);
add("site", "aeo-geo", `acesso-${name}`, r.res.status === 200, `HTTP ${r.res.status}`);
}
// A Cloudflare (Email Address Obfuscation) troca e-mails por /cdn-cgi/l/email-protection para quem é robô: buscadores e IA não veem o contato
const botHome = await get(`${BASE}/`, AI_BOTS[0][1]);
add("site", "aeo-geo", "email-visivel-para-robos", !/email-protection|\[email(&#160;|\s)protected\]/.test(botHome.body), /email-protection/.test(botHome.body) ? "HTML servido a robôs tem e-mail ofuscado" : "");
const llms = await get(`${BASE}/llms.txt`);
// informativo: a política do projeto exclui llms.txt (nenhum sistema de IA o usa, segundo o Google); ausência é o esperado
add("site", "aeo-geo", "llms-txt-ausente-por-politica", llms.res.status === 404, `HTTP ${llms.res.status}`);
const titles = new Set();
const descs = new Set();
const pages = [];
for (const url of urls) pages.push(await auditUrl(url, titles, descs));
// links internos quebrados (amostra: todos os destinos únicos das páginas auditadas)
const targets = new Set();
for (const pg of pages.filter(Boolean))
for (const a of pg.p.anchors) {
const h = a.href?.split("#")[0];
if (h && (h.startsWith("/") && !h.startsWith("//"))) targets.add(h);
}
let broken = 0;
const brokenList = [];
for (const t of targets) {
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(() => null);
if (!r || r.res.status >= 400) {
broken++;
brokenList.push(`${t} (${r?.res.status ?? "erro"})`);
}
}
add("site", "técnico", "sem-links-internos-quebrados", broken === 0, `${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`);
const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, "")));
const orphanCandidates = [...inSitemap].filter((u) => {
const path = u.replace(SITE, "") || "/";
return path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path);
});
add("site", "técnico", "sem-paginas-orfas-no-sitemap", orphanCandidates.length === 0, orphanCandidates.join(", "));
// links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado)
const ghLinks = new Set();
for (const pg of pages.filter(Boolean))
for (const a of pg.p.anchors) if (/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")) ghLinks.add(a.href.replace(/\/$/, ""));
const ghBroken = [];
for (const url of ghLinks) {
const r = await get(url, UA, { method: "HEAD" }).catch(() => null);
if (!r || r.res.status === 404) ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
}
add("site", "técnico", "links-github-acessiveis", ghBroken.length === 0, ghBroken.length ? `404 para visitantes: ${ghBroken.join(", ")}` : `${ghLinks.size} repositórios`);
const groups = {};
for (const c of checks) {
const g = (groups[c.group] ??= { ok: 0, total: 0 });
g.total++;
if (c.ok) g.ok++;
}
const out = { base: BASE, at: new Date().toISOString(), urls: urls.length, groups, checks };
if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2));
console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`);
for (const [g, v] of Object.entries(groups)) console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
const failing = checks.filter((c) => !c.ok);
console.log(`\nFalhas (${failing.length}):`);
const byId = {};
for (const f of failing) (byId[f.id] ??= []).push(f);
for (const [id, list] of Object.entries(byId)) console.log(` - ${id} (${list.length}): ${list.slice(0, 4).map((f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`).join(" | ")}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/** Compara duas execuções de scripts/seo-audit.mjs: node scripts/seo-compare.mjs antes.json depois.json */
import { readFileSync } from "node:fs";
const [beforePath, afterPath] = process.argv.slice(2);
if (!beforePath || !afterPath) {
console.error("uso: node scripts/seo-compare.mjs antes.json depois.json");
process.exit(1);
}
const before = JSON.parse(readFileSync(beforePath, "utf8"));
const after = JSON.parse(readFileSync(afterPath, "utf8"));
const key = (c) => `${c.scope}::${c.id}`;
const prev = new Map(before.checks.map((c) => [key(c), c]));
const next = new Map(after.checks.map((c) => [key(c), c]));
console.log(`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`);
console.log("grupo antes depois");
for (const g of new Set([...Object.keys(before.groups), ...Object.keys(after.groups)])) {
const a = before.groups[g] ?? { ok: 0, total: 0 };
const b = after.groups[g] ?? { ok: 0, total: 0 };
console.log(`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`);
}
const fixed = [];
const regressed = [];
for (const [k, c] of next) {
const p = prev.get(k);
if (p && !p.ok && c.ok) fixed.push(c);
if (p && p.ok && !c.ok) regressed.push(c);
if (!p && !c.ok) regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` });
}
console.log(`\nCorrigidos (${fixed.length}):`);
for (const c of fixed) console.log(` + ${c.scope} ${c.id}`);
console.log(`\nPiorou (${regressed.length}):`);
for (const c of regressed) console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`);
process.exit(regressed.length ? 2 : 0);