feat(story): celular CC0 com site vivo na tela, ReelDirector scrub em todas as seções e filmstrip pinado de prints reais

This commit is contained in:
Evandro Fonseca Junior committed 2026-08-30 20:00:56 -03:00
1 parent 8be2ba572d
commit ecadb7c540
20 files changed
+637 -83

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Credits / Third-party assets
## 3D model — “Phone”
- Author: **Quaternius** (https://quaternius.com)
- Source: https://poly.pizza/m/k2kgBepoMU
- License: **CC0 1.0 Universal (Public Domain)**
- Used in: `app/components/story/StoryStage3D.tsx` (hero scrollytelling reel),
file at `public/models/phone-quaternius.glb`.
- Modifications: material overrides (body/frame metallic dark; the model's
`Black` surface renders a live CanvasTexture of the portfolio site). CC0 does
not require attribution; credit given anyway.
## Project screenshots
- `public/assets/prints/*.png` are captures taken with headless Chrome from
production deployments owned by the site author (evandro.dev.br), used as
on-screen content inside the 3D phone and section animations.
+6 -4
View File
@@ -91,7 +91,7 @@ export default function About() {
description="Atuo na interseção entre arquitetura de software, integrações de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade." description="Atuo na interseção entre arquitetura de software, integrações de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
disableChildrenAnimation disableChildrenAnimation
aside={ aside={
<div className="mt-sp-4 ds-metric"> <div className="mt-sp-4 ds-metric" data-reel="left">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" /> <span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
Garuva, SC | Remote worldwide Garuva, SC | Remote worldwide
</div> </div>
@@ -120,7 +120,9 @@ export default function About() {
<div className="ds-kicker">{step.label}</div> <div className="ds-kicker">{step.label}</div>
<DrawLine delay={0.03} /> <DrawLine delay={0.03} />
<h3 className="ds-card-title">{step.title}</h3> <h3 className="ds-card-title">{step.title}</h3>
<p className="ds-copy">{step.content}</p> <p className="ds-copy" data-reel="right">
{step.content}
</p>
</motion.article> </motion.article>
))} ))}
</motion.div> </motion.div>
@@ -141,13 +143,13 @@ export default function About() {
<div className="ds-kicker">Technical profile</div> <div className="ds-kicker">Technical profile</div>
<DrawLine delay={0.05} /> <DrawLine delay={0.05} />
<h3 className="ds-card-title">Base técnica e postura de entrega</h3> <h3 className="ds-card-title">Base técnica e postura de entrega</h3>
<p className="ds-copy"> <p className="ds-copy" data-reel="top">
Sou engenheiro da computação e desenvolvedor de software full-stack, Sou engenheiro da computação e desenvolvedor de software full-stack,
atuando ha 5 anos com foco em integrar camadas que normalmente ficam atuando ha 5 anos com foco em integrar camadas que normalmente ficam
separadas: hardware, servicos, operacao e interface. Meu trabalho e separadas: hardware, servicos, operacao e interface. Meu trabalho e
dar forma clara ao que costuma nascer fragmentado. dar forma clara ao que costuma nascer fragmentado.
</p> </p>
<div className="mt-sp-6 space-y-sp-4"> <div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => ( {PROFILE_POINTS.map((point) => (
<div <div
key={point.title} key={point.title}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function CompanyLogos() {
// Animated infinite scroll // Animated infinite scroll
return ( return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden"> <section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="py-sp-8"> <div className="py-sp-8" data-reel="zoom" data-reel-distance="40">
<motion.div <motion.div
className="flex gap-sp-12 items-center" className="flex gap-sp-12 items-center"
animate={{ animate={{
+15 -5
View File
@@ -90,19 +90,29 @@ export default function Contact() {
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }} whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }} transition={{ type: "spring", stiffness: 180, damping: 18 }}
> >
<div className="ds-kicker">Availability</div> <div className="ds-kicker" data-reel="left">
Availability
</div>
<DrawLine delay={0.04} /> <DrawLine delay={0.04} />
<h3 className="ds-card-title">Resposta inicial em ate 24h</h3> <h3 className="ds-card-title" data-reel="top">
<p className="ds-copy"> Resposta inicial em ate 24h
</h3>
<p className="ds-copy" data-reel="right">
Ideal para discovery tecnico, revisao de arquitetura, rescue de Ideal para discovery tecnico, revisao de arquitetura, rescue de
projeto, performance ou construcao de produto com ownership real. projeto, performance ou construcao de produto com ownership real.
</p> </p>
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]"> <div
className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]"
data-reel-stagger="bottom"
>
<div>WhatsApp: {whatsAppDisplay}</div> <div>WhatsApp: {whatsAppDisplay}</div>
<div>E-mail: evandrofonsecajunior@gmail.com</div> <div>E-mail: evandrofonsecajunior@gmail.com</div>
<div>Base: Garuva, Santa Catarina | atuacao remota</div> <div>Base: Garuva, Santa Catarina | atuacao remota</div>
</div> </div>
<div className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]"> <div
className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]"
data-reel="fade"
>
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
conversa tecnica objetiva. conversa tecnica objetiva.
</div> </div>
+3 -3
View File
@@ -79,7 +79,7 @@ export default function Experience({
<div className="max-w-[720px]"> <div className="max-w-[720px]">
<div className="ds-kicker">{exp.period}</div> <div className="ds-kicker">{exp.period}</div>
<DrawLine delay={0.04} /> <DrawLine delay={0.04} />
<h3 className="ds-card-title">{exp.title}</h3> <h3 className="ds-card-title" data-reel="left">{exp.title}</h3>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]"> <p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
{exp.company} • {exp.type} {exp.company} • {exp.type}
</p> </p>
@@ -87,7 +87,7 @@ export default function Experience({
<div className="ds-metric">{exp.location}</div> <div className="ds-metric">{exp.location}</div>
</div> </div>
<p className="ds-copy">{exp.description}</p> <p className="ds-copy" data-reel="right">{exp.description}</p>
<ul className="ds-list"> <ul className="ds-list">
{exp.achievements?.map((achievement) => ( {exp.achievements?.map((achievement) => (
@@ -95,7 +95,7 @@ export default function Experience({
))} ))}
</ul> </ul>
<ul className="ds-tags mt-sp-6"> <ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{exp.technologies?.slice(0, 6).map((tech) => ( {exp.technologies?.slice(0, 6).map((tech) => (
<li key={tech} className="ds-tag"> <li key={tech} className="ds-tag">
{tech} {tech}
+5 -2
View File
@@ -43,7 +43,10 @@ export default function LighthouseMetrics() {
dark dark
disableChildrenAnimation={false} disableChildrenAnimation={false}
> >
<div className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4"> <div
className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4"
data-reel="fade"
>
{METRICS.map((metric, i) => ( {METRICS.map((metric, i) => (
<motion.article <motion.article
key={metric.label} key={metric.label}
@@ -96,7 +99,7 @@ export default function LighthouseMetrics() {
</div> </div>
{/* Content Details */} {/* Content Details */}
<div className="flex-1"> <div className="flex-1" data-reel-stagger="bottom">
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]"> <h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
{metric.label} {metric.label}
</h3> </h3>
+3 -3
View File
@@ -93,7 +93,7 @@ export default function Projects({
<p className="ds-copy">{project.description}</p> <p className="ds-copy">{project.description}</p>
<ul className="ds-tags mt-sp-6"> <ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => ( {project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag"> <li key={tech} className="ds-tag">
{tech} {tech}
@@ -101,7 +101,7 @@ export default function Projects({
))} ))}
</ul> </ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3"> <div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
{project.githubUrl && ( {project.githubUrl && (
<MagneticButton <MagneticButton
as="a" as="a"
@@ -131,7 +131,7 @@ export default function Projects({
))} ))}
</motion.div> </motion.div>
<div className="mt-sp-8 flex flex-wrap gap-sp-3"> <div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
<MagneticButton <MagneticButton
as="a" as="a"
href="/cases" href="/cases"
+5 -3
View File
@@ -69,13 +69,15 @@ export default function Skills({
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }} transition={{ type: "spring", stiffness: 180, damping: 18 }}
> >
<h3 className="ds-card-title !mt-0">{category.category}</h3> <h3 className="ds-card-title !mt-0" data-reel="left">
{category.category}
</h3>
<DrawLine delay={0.05} /> <DrawLine delay={0.05} />
<p className="ds-copy !mt-sp-4"> <p className="ds-copy !mt-sp-4" data-reel="right">
Ferramentas que uso para construir com clareza tecnica e ritmo de Ferramentas que uso para construir com clareza tecnica e ritmo de
entrega. entrega.
</p> </p>
<ul className="ds-tags"> <ul className="ds-tags" data-reel-stagger="fade">
{category.skills.map((skill) => ( {category.skills.map((skill) => (
<li key={skill} className="ds-tag"> <li key={skill} className="ds-tag">
{skill} {skill}
+5 -5
View File
@@ -128,11 +128,11 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
</p> </p>
</div> </div>
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"> <p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel="right">
{tier.description} {tier.description}
</p> </p>
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"> <ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel-stagger="fade">
{tier.deliverables.map((deliverable) => ( {tier.deliverables.map((deliverable) => (
<li key={deliverable} className="pl-sp-1"> <li key={deliverable} className="pl-sp-1">
{deliverable} {deliverable}
@@ -202,10 +202,10 @@ export function WorkModalities() {
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[760px]"> <div className="max-w-[760px]">
<div className="ds-kicker">Páginas de serviço</div> <div className="ds-kicker">Páginas de serviço</div>
<h3 className="ds-card-title !mt-sp-3"> <h3 className="ds-card-title !mt-sp-3" data-reel="left">
Especialidades organizadas por intenção de busca Especialidades organizadas por intenção de busca
</h3> </h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]"> <p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
Se você chegou buscando uma stack, um tipo de consultoria ou um Se você chegou buscando uma stack, um tipo de consultoria ou um
contexto técnico específico, aqui está o caminho mais direto para contexto técnico específico, aqui está o caminho mais direto para
a página certa. a página certa.
@@ -221,7 +221,7 @@ export function WorkModalities() {
</MagneticButton> </MagneticButton>
</div> </div>
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3"> <div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3" data-reel-stagger="left">
{serviceHubLinks.map((service) => ( {serviceHubLinks.map((service) => (
<Link <Link
key={service.href} key={service.href}
@@ -10,6 +10,8 @@ import Hero from "../Hero";
import Projects from "../Projects"; import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading"; import { SectionLoading } from "../SectionLoading";
import ScrollStory from "../story/ScrollStory"; import ScrollStory from "../story/ScrollStory";
import ReelDirector from "../story/ReelDirector";
import PrintReel from "../story/PrintReel";
import CinematicSection from "./CinematicSection"; import CinematicSection from "./CinematicSection";
const About = dynamic(() => import("../About"), { const About = dynamic(() => import("../About"), {
@@ -52,6 +54,7 @@ export default function CinematicHome({
return ( return (
<div className="flex min-h-screen flex-col overflow-x-clip"> <div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main"> <main className="ds-main">
<ReelDirector />
<Hero /> <Hero />
{/* Marquee divider after hero */} {/* Marquee divider after hero */}
@@ -69,6 +72,9 @@ export default function CinematicHome({
)} )}
/> />
{/* Palco 2: filmstrip horizontal pinado com prints reais dos deployments */}
<PrintReel />
<CinematicSection effect="parallax-depth"> <CinematicSection effect="parallax-depth">
<About /> <About />
</CinematicSection> </CinematicSection>
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { DrawLine } from "../animations/DrawLine";
import HorizontalScroll from "../cinematic/HorizontalScroll";
import { Section } from "../Section";
/**
* Palco extra estilo product-page: filmstrip horizontal PINNADO com prints
* reais capturados dos deployments em produção (public/assets/prints).
* O scroll vertical empurra a faixa na horizontal (scrub), cada card entra
* com rotateY/scale ligados ao mesmo trilho — sensação de carrossel de vídeo.
*/
const PRINTS = [
{
src: "/assets/prints/print-nutriscan.png",
name: "NutriScan",
desc: "PWA de dados nutricionais sobre Open Food Facts — busca, Nutri-Score e NOVA.",
url: "https://nutriscan.evandro.dev.br",
accent: true,
},
{
src: "/assets/prints/print-scplus.png",
name: "SCPLUS",
desc: "Educação e tecnologia sustentável em Garuva-SC; interface institucional viva.",
url: "https://scplus.evandro.dev.br",
accent: false,
},
{
src: "/assets/prints/print-sev.png",
name: "SEV Moda Íntima",
desc: "E-commerce em produção com checkout e catálogo — Next.js ponta a ponta.",
url: "https://sevmodaintima.com.br",
accent: true,
},
{
src: "/assets/prints/print-asservicos.png",
name: "AS Serviços",
desc: "Presença digital SSR para operação de serviços, pronta para crescer.",
url: "https://asservicos.com",
accent: false,
},
];
export default function PrintReel() {
return (
<Section
id="print-reel"
eyebrow="No ar, rodando"
title="Prints ao vivo dos sistemas em produção"
description="Capturas reais dos deployments — role e a faixa corre na horizontal como um reel."
dark
disableChildrenAnimation
>
<div className="flex h-[100dvh] items-center">
<HorizontalScroll className="w-full">
{PRINTS.map((print) => (
<a
key={print.name}
data-scroll-item
href={print.url}
target="_blank"
rel="noopener noreferrer"
className="group flex w-[min(78vw,380px)] shrink-0 flex-col"
>
{/* moldura de navegador */}
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
<span
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
/>
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
{new URL(print.url).hostname}
</span>
</div>
</div>
<div className="relative h-[420px] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
{/* o print real, deslizando de leve dentro da moldura (Ken Burns) */}
<img
src={print.src}
alt={`Captura do projeto ${print.name} em produção`}
loading="lazy"
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.04] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-6 group-hover:scale-100"
/>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_60%,rgba(10,10,10,.85)_100%)]" />
</div>
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-sp-5">
<div className="flex items-center justify-between">
<h3 className="font-display text-[18px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
{print.name}
</h3>
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
ao vivo ↗
</span>
</div>
<DrawLine delay={0.1} />
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
{print.desc}
</p>
</div>
</a>
))}
</HorizontalScroll>
</div>
</Section>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef } from "react";
/**
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
*
* Contrato (declarativo, via atributos data-*):
* [data-reel="left|right|top|bottom|zoom|fade"]
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
* [data-reel-stagger="left|right|top|bottom|zoom|fade"]
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
* data-reel-distance="64" (opcional: px de deslocamento)
*
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
*/
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
const DISTANCE = 64;
function fromVars(dir: Direction, dist: number) {
const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
if (dir === "left") return { ...base, x: -dist };
if (dir === "right") return { ...base, x: dist };
if (dir === "top") return { ...base, y: -dist * 0.75 };
if (dir === "bottom") return { ...base, y: dist };
if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
return { ...base, scale: 1.02 };
}
const TO_VARS = {
opacity: 1,
x: 0,
y: 0,
scale: 1,
filter: "blur(0px)",
ease: "none",
};
export default function ReelDirector() {
const alive = useRef(true);
useEffect(() => {
alive.current = true;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return undefined;
let teardown = () => {};
const boot = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (!alive.current) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const scan = () => {
for (const el of document.querySelectorAll<HTMLElement>(
"[data-reel]:not([data-reel-init])",
)) {
el.dataset.reelInit = "1";
const dir = (el.dataset.reel || "bottom") as Direction;
const dist = Number(el.dataset.reelDistance || DISTANCE);
gsap.fromTo(el, fromVars(dir, dist), {
...TO_VARS,
scrollTrigger: {
trigger: el,
start: el.dataset.reelStart || "top 88%",
end: el.dataset.reelEnd || "top 26%",
scrub: 0.6,
},
});
}
for (const container of document.querySelectorAll<HTMLElement>(
"[data-reel-stagger]:not([data-reel-init])",
)) {
container.dataset.reelInit = "1";
const dir = (container.dataset.reelStagger || "bottom") as Direction;
const dist = Number(container.dataset.reelDistance || DISTANCE);
const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) continue;
gsap.fromTo(
children,
fromVars(dir, dist),
{
...TO_VARS,
stagger: { each: 0.09 },
scrollTrigger: {
trigger: container,
start: container.dataset.reelStart || "top 85%",
end: container.dataset.reelEnd || "top 25%",
scrub: 0.6,
},
},
);
}
};
scan();
// seções chegam via next/dynamic depois do boot — observa nós novos
const observer = new MutationObserver(() => scan());
observer.observe(document.body, { childList: true, subtree: true });
teardown = () => {
observer.disconnect();
ctx.revert();
};
});
};
void boot();
return () => {
alive.current = false;
teardown();
};
}, []);
return null;
}
+4 -4
View File
@@ -65,10 +65,10 @@ describe("ScrollStory", () => {
}); });
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => { it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
mockMatchMedia(false);
const { container } = render(<ScrollStory {...props} />); const { container } = render(<ScrollStory {...props} />);
await waitFor(() => await waitFor(
expect(container.querySelector("[data-story]")).toBeInTheDocument(), () => expect(container.querySelector("[data-story]")).toBeInTheDocument(),
{ timeout: 10000 },
); );
expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4); expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4);
expect(container.querySelector("[data-story-index]")).toHaveTextContent("01"); expect(container.querySelector("[data-story-index]")).toHaveTextContent("01");
@@ -76,5 +76,5 @@ describe("ScrollStory", () => {
// count-up é alimentado pela timeline: markup carrega o alvo real // count-up é alimentado pela timeline: markup carrega o alvo real
const num = container.querySelector("[data-story-num]"); const num = container.querySelector("[data-story-num]");
expect(num?.getAttribute("data-story-num-to")).toBe("5"); expect(num?.getAttribute("data-story-num-to")).toBe("5");
}); }, 20000);
}); });
+152 -53
View File
@@ -1,72 +1,160 @@
"use client"; "use client";
import { Canvas, useFrame } from "@react-three/fiber"; import { Canvas, useFrame } from "@react-three/fiber";
import { Environment, Lightformer, RoundedBox, Sparkles } from "@react-three/drei"; import {
import { useRef } from "react"; Environment,
Lightformer,
RoundedBox,
Sparkles,
useGLTF,
} from "@react-three/drei";
import {
Component,
Suspense,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import * as THREE from "three"; import * as THREE from "three";
import { createPhoneScreen, type ScreenPainter } from "./phoneScreenTexture";
type ProgressRef = { current: number }; type ProgressRef = { current: number };
/** /**
* Palco WebGL do scrollytelling: um "dispositivo" 3D procedural que gira, * Palco WebGL do scrollytelling.
* inclina e aproxima conforme o progresso do scroll (ref compartilhada — sem * Modelo 3D do aparelho: "Phone" by Quaternius — CC0 (Public Domain), via
* re-render do React por frame). O lerp interno suaviza o scrub em touch. * poly.pizza (https://poly.pizza/m/k2kgBepoMU). Creditado em CREDITS.md.
* Composição estilo product-page: dispositivo no terço direito, texto à esquerda. * A tela recebe um CanvasTexture com o mini-site do portfólio (prints reais dos
* deployments) rolando conforme o progresso do scroll — o celular está "ligado".
*/ */
function DeviceRig({ progress }: { progress: ProgressRef }) {
function motionRig(g: THREE.Object3D, p: number) {
// turntable contínuo: uma volta + extra, com inclinação de cena a cena
g.rotation.y = -0.7 + p * Math.PI * 2.2;
g.rotation.x = 0.08 + Math.sin(p * Math.PI) * 0.22;
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.05;
g.position.x = 1.4;
g.position.y = Math.sin(p * Math.PI * 2) * 0.2;
g.scale.setScalar(0.8 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.16);
}
function ScreenPhone({ progress }: { progress: ProgressRef }) {
// CC0: Quaternius "Phone" — poly.pizza/m/k2kgBepoMU
const { scene } = useGLTF("/models/phone-quaternius.glb");
const rig = useRef<THREE.Group>(null); const rig = useRef<THREE.Group>(null);
const halo = useRef<THREE.Mesh>(null); const halo = useRef<THREE.Mesh>(null);
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
const glow = useRef<THREE.PointLight>(null);
const smooth = useRef(0); const smooth = useRef(0);
const [painter] = useState<ScreenPainter>(() => createPhoneScreen());
const texture = useMemo(() => {
const t = new THREE.CanvasTexture(painter.canvas);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 4;
return t;
}, [painter]);
useFrame((_, dt) => { // materiais: corpo metálico; primitiva "Black" = tela com o site vivo
const materials = useMemo(() => {
const body = new THREE.MeshStandardMaterial({
color: "#232429",
metalness: 0.85,
roughness: 0.38,
});
const frame = new THREE.MeshStandardMaterial({
color: "#3a3b42",
metalness: 0.9,
roughness: 0.3,
});
const screen = new THREE.MeshStandardMaterial({
color: "#000000",
map: texture,
emissive: "#ffffff",
emissiveMap: texture,
emissiveIntensity: 1.15,
roughness: 0.4,
metalness: 0,
});
return { body, frame, screen };
}, [texture]);
useFrame((state, dt) => {
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7); smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
const p = smooth.current; const p = smooth.current;
const g = rig.current; if (rig.current) motionRig(rig.current, p);
if (!g) return;
// tomada contínua: ~360° + um extra, como um turntable pré-renderizado
g.rotation.y = -0.7 + p * Math.PI * 2.2;
g.rotation.x = 0.1 + Math.sin(p * Math.PI) * 0.28;
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.06;
g.position.x = 2.1;
g.position.y = Math.sin(p * Math.PI * 2) * 0.22;
// respiro de escala durante o reel (zoom de câmera sutil)
g.scale.setScalar(0.62 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.1);
if (halo.current) { if (halo.current) {
halo.current.rotation.z += dt * 0.25; halo.current.rotation.z += dt * 0.25;
const m = halo.current.material as THREE.MeshBasicMaterial; const m = halo.current.material as THREE.MeshBasicMaterial;
m.opacity = 0.1 + p * 0.08; m.opacity = 0.1 + p * 0.08;
} }
if (screenMat.current) { // pinta o mini-site ~4fps quando há mudança real (quantizada no painter)
// tela "liga" cena a cena: 4 pulsos ao longo do reel const t = state.clock.elapsedTime * 1000;
screenMat.current.emissiveIntensity = if (painter.ready() && painter.paint(p, t)) {
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5; texture.needsUpdate = true;
}
if (glow.current) {
glow.current.intensity = 2.2 + Math.sin(p * Math.PI * 4) * 1.4;
} }
}); });
const cloned = useMemo(() => {
const root = scene.clone(true);
root.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return;
const name = (obj.material as THREE.Material & { name?: string })?.name ?? "";
if (name === "Black") {
obj.material = materials.screen;
// o GLB não tem TEXCOORD — UV plano na face (geo X/Z local, topo = +Z)
const geo = obj.geometry;
geo.computeBoundingBox();
const bb = geo.boundingBox!;
const sx = bb.max.x - bb.min.x || 1;
const sz = bb.max.z - bb.min.z || 1;
const pos = geo.attributes.position as THREE.BufferAttribute;
const uv = new Float32Array(pos.count * 2);
for (let i = 0; i < pos.count; i++) {
uv[i * 2] = (pos.getX(i) - bb.min.x) / sx;
uv[i * 2 + 1] = (pos.getZ(i) - bb.min.z) / sz;
}
geo.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
} else {
obj.material = name === "Grey" ? materials.frame : materials.body;
}
obj.frustumCulled = false;
});
// a primitiva Black aponta +Z mundo após a rotação -90°X do node — já dá de
// frente para a câmera em p=0; nenhum flip extra.
// o modelo mede ~0.9 unidades de altura; enquadrar no stage (~2.5u)
root.scale.setScalar(2.75);
return root;
}, [scene, materials]);
return (
<group ref={rig}>
<primitive object={cloned} />
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.2, -0.3]}>
<torusGeometry args={[1.9, 0.014, 10, 128]} />
<meshBasicMaterial color="#ccff00" transparent opacity={0.16} toneMapped={false} />
</mesh>
<pointLight position={[0, 0, 1.9]} color="#ccff00" intensity={0.6} distance={5} />
</group>
);
}
/** Fallback procedural (GLTF falhou / SwiftShader): rig de RoundedBox. */
function ProceduralPhone({ progress }: { progress: ProgressRef }) {
const rig = useRef<THREE.Group>(null);
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
const smooth = useRef(0);
useFrame((_, dt) => {
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
const p = smooth.current;
if (rig.current) motionRig(rig.current, p);
if (screenMat.current)
screenMat.current.emissiveIntensity =
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
});
return ( return (
<group ref={rig}> <group ref={rig}>
{/* corpo do dispositivo */}
<RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}> <RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}>
<meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} /> <meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} />
</RoundedBox> </RoundedBox>
{/* borda luminosa (bezel slash) */}
<RoundedBox args={[1.7, 3.4, 0.1]} radius={0.17} smoothness={4} position={[0, 0, -0.01]}>
<meshStandardMaterial
color="#0a0a0a"
emissive="#ccff00"
emissiveIntensity={0.22}
metalness={0.6}
roughness={0.5}
/>
</RoundedBox>
{/* tela emissiva */}
<mesh position={[0, 0, 0.098]}> <mesh position={[0, 0, 0.098]}>
<planeGeometry args={[1.42, 3.06]} /> <planeGeometry args={[1.42, 3.06]} />
<meshStandardMaterial <meshStandardMaterial
@@ -77,26 +165,34 @@ function DeviceRig({ progress }: { progress: ProgressRef }) {
toneMapped={false} toneMapped={false}
transparent transparent
opacity={0.92} opacity={0.92}
roughness={0.3}
metalness={0.1}
/> />
</mesh> </mesh>
{/* halo orbital */}
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.1, -0.3]}>
<torusGeometry args={[1.9, 0.014, 10, 128]} />
<meshBasicMaterial color="#ccff00" transparent opacity={0.2} toneMapped={false} />
</mesh>
<pointLight ref={glow} position={[0, 0, 1.6]} color="#ccff00" intensity={2.4} distance={6} />
</group> </group>
); );
} }
// Boundary funcional: se o GLTF estourar, mantém o rig procedural no lugar.
class PhoneBoundary extends Component<
{ progress: ProgressRef },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render(): ReactNode {
if (this.state.failed)
return <ProceduralPhone progress={this.props.progress} />;
return <ScreenPhone progress={this.props.progress} />;
}
}
function CameraRig({ progress }: { progress: ProgressRef }) { function CameraRig({ progress }: { progress: ProgressRef }) {
const smooth = useRef(0); const smooth = useRef(0);
useFrame(({ camera }, dt) => { useFrame(({ camera }, dt) => {
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7); smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
const p = smooth.current; const p = smooth.current;
// leve steadicam: aproxima no meio da história // steadicam sutil: aproxima no meio da história
camera.position.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8; camera.position.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8;
camera.position.y = -0.3 + p * 0.6; camera.position.y = -0.3 + p * 0.6;
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
@@ -111,9 +207,12 @@ export default function StoryStage3D({ progress }: { progress: ProgressRef }) {
camera={{ position: [0, 0, 7.8], fov: 30 }} camera={{ position: [0, 0, 7.8], fov: 30 }}
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }} gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
> >
<ambientLight intensity={0.32} /> <ambientLight intensity={0.55} />
<directionalLight position={[4, 6, 5]} intensity={1.1} /> <directionalLight position={[4, 6, 5]} intensity={1.4} />
<DeviceRig progress={progress} /> <directionalLight position={[-5, -2, 6]} intensity={0.6} color="#cfd2da" />
<Suspense fallback={<ProceduralPhone progress={progress} />}>
<PhoneBoundary progress={progress} />
</Suspense>
<CameraRig progress={progress} /> <CameraRig progress={progress} />
<Sparkles count={70} scale={[9, 5.5, 3.2]} size={2.4} speed={0.32} opacity={0.5} color="#ccff00" /> <Sparkles count={70} scale={[9, 5.5, 3.2]} size={2.4} speed={0.32} opacity={0.5} color="#ccff00" />
<Environment resolution={64} frames={1}> <Environment resolution={64} frames={1}>
+183
View File
@@ -0,0 +1,183 @@
// Gera a textura "site vivo" da tela do celular: um mini-portfolio desenhado em
// canvas 2D, com os prints reais dos projetos em produção rolando verticalmente
// conforme o progresso do reel — como se o site estivesse aberto no aparelho.
// Fotos dos projetos: capturadas ao vivo dos deployments (headless Chrome).
// CC0 model: "Phone" by Quaternius (https://quaternius.com) via poly.pizza.
type Shot = { img: HTMLImageElement; name: string; tag: string };
const SHOTS: Array<{ src: string; name: string; tag: string }> = [
{ src: "/assets/prints/print-nutriscan.png", name: "NutriScan", tag: "Open Food Facts · PWA" },
{ src: "/assets/prints/print-scplus.png", name: "SCPLUS", tag: "Educação + IoT · Garuva-SC" },
{ src: "/assets/prints/print-sev.png", name: "SEV Moda Íntima", tag: "E-commerce · Next.js" },
{ src: "/assets/prints/print-asservicos.png", name: "AS Serviços", tag: "Institucional · SSR" },
];
export type ScreenPainter = {
canvas: HTMLCanvasElement;
/** desenha o frame para o progresso p∈[0,1] do reel; retorna true se mudou */
paint: (p: number, t: number) => boolean;
ready: () => boolean;
};
const W = 512;
const H = 1024;
const INK = "#0a0a0a";
const PAPER = "#f4f2ed";
const SLASH = "#ccff00";
export function createPhoneScreen(): ScreenPainter {
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
const images: Shot[] = [];
let lastKey = "";
for (const s of SHOTS) {
const img = new Image();
img.decoding = "async";
img.onload = () => images.push({ img, name: s.name, tag: s.tag });
img.src = s.src;
}
function roundRect(x: number, y: number, w: number, h: number, r: number) {
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function wrap(text: string, x: number, y: number, maxW: number, lh: number) {
if (!ctx) return;
const words = text.split(" ");
let line = "";
let yy = y;
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width > maxW) {
ctx.fillText(line, x, yy);
line = word;
yy += lh;
} else line = candidate;
}
if (line) ctx.fillText(line, x, yy);
}
function drawHero(offset: number, t: number) {
if (!ctx) return;
if (offset > H || offset < -H) return;
ctx.save();
ctx.translate(0, offset);
const pulse = 0.6 + 0.4 * Math.sin(t / 600);
ctx.fillStyle = SLASH;
ctx.globalAlpha = 0.14 + pulse * 0.06;
ctx.fillRect(0, 60, W, 2);
ctx.globalAlpha = 1;
ctx.fillStyle = "rgba(244,242,237,.55)";
ctx.font = "600 20px ui-monospace, monospace";
ctx.fillText("PORTFÓLIO · AO VIVO", 36, 150);
ctx.fillStyle = PAPER;
ctx.font = "800 72px system-ui, sans-serif";
ctx.fillText("Software", 36, 260);
ctx.fillText("que", 36, 340);
ctx.fillStyle = SLASH;
ctx.fillText("opera.", 36, 420);
ctx.fillStyle = "rgba(244,242,237,.66)";
ctx.font = "400 28px system-ui, sans-serif";
wrap("Engenharia de sistemas que precisam funcionar de verdade.", 36, 500, W - 72, 40);
ctx.fillStyle = SLASH;
roundRect(36, 700, 250, 72, 36);
ctx.fill();
ctx.fillStyle = INK;
ctx.font = "700 26px ui-monospace, monospace";
ctx.fillText("VER PROJETOS ↓", 60, 744);
ctx.restore();
}
function drawProject(shot: Shot, offset: number, i: number, t: number) {
if (!ctx) return;
ctx.save();
ctx.translate(0, offset);
ctx.fillStyle = "rgba(244,242,237,.5)";
ctx.font = "600 18px ui-monospace, monospace";
ctx.fillText(`EM PRODUÇÃO · 0${i + 1}`, 36, 110);
ctx.fillStyle = PAPER;
ctx.font = "800 46px system-ui, sans-serif";
ctx.fillText(shot.name, 36, 168);
ctx.fillStyle = "rgba(204,255,0,.85)";
ctx.font = "400 22px system-ui, sans-serif";
ctx.fillText(shot.tag, 36, 204);
// o print real, recortado, com leve auto-scroll dentro do card
const cardX = 36;
const cardY = 236;
const cardW = W - 72;
const cardH = 620;
ctx.save();
roundRect(cardX, cardY, cardW, cardH, 28);
ctx.clip();
ctx.fillStyle = "#111";
ctx.fillRect(cardX, cardY, cardW, cardH);
const scale = cardW / shot.img.width;
const drift =
(Math.sin(t / 1400 + i * 2.1) * 0.5 + 0.5) *
Math.max(0, shot.img.height * scale - cardH);
ctx.drawImage(shot.img, cardX, cardY - drift, cardW, shot.img.height * scale);
const grd = ctx.createLinearGradient(cardX, cardY, cardX, cardY + cardH);
grd.addColorStop(0, "rgba(255,255,255,.08)");
grd.addColorStop(0.12, "rgba(255,255,255,0)");
ctx.fillStyle = grd;
ctx.fillRect(cardX, cardY, cardW, cardH);
ctx.restore();
ctx.strokeStyle = "rgba(255,255,255,.12)";
ctx.lineWidth = 2;
roundRect(cardX, cardY, cardW, cardH, 28);
ctx.stroke();
ctx.restore();
}
function paint(p: number, t: number) {
if (!ctx || images.length === 0) return false;
const key = `${p.toFixed(4)}|${Math.floor(t / 250)}|${images.length}`;
if (key === lastKey) return false;
lastKey = key;
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
// status bar do "aparelho"
ctx.fillStyle = PAPER;
ctx.font = "600 22px ui-monospace, monospace";
ctx.fillText("21:37", 28, 38);
ctx.fillStyle = SLASH;
ctx.fillRect(W - 96, 22, 44, 14);
ctx.fillStyle = PAPER;
ctx.font = "600 16px ui-monospace, monospace";
ctx.fillText("E:/", W - 40, 36);
// o mini-site rolando: hero + página por projeto
const pageH = H - 60;
const y = p * images.length * pageH;
drawHero(-y, t);
images.forEach((shot, i) => {
const top = (i + 1) * pageH - y;
if (top > H || top < -pageH) return;
drawProject(shot, top, i, t);
});
return true;
}
return {
canvas,
paint,
ready: () => images.length > 0,
};
}
export const PHONE_SCREEN_W = W;
export const PHONE_SCREEN_H = H;
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.