From 8be2ba572d52e1ce9e0f395e78c0c442d7070e2f Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sun, 30 Aug 2026 18:15:02 -0300 Subject: [PATCH] feat(story): reel scrollytelling scrubbed por scroll com palco WebGL 3D --- app/components/cinematic/CinematicHome.tsx | 13 +- app/components/story/ScrollStory.test.tsx | 80 +++++ app/components/story/ScrollStory.tsx | 377 +++++++++++++++++++++ app/components/story/StoryStage3D.tsx | 126 +++++++ 4 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 app/components/story/ScrollStory.test.tsx create mode 100644 app/components/story/ScrollStory.tsx create mode 100644 app/components/story/StoryStage3D.tsx diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx index fd31a5b..ed26cc2 100644 --- a/app/components/cinematic/CinematicHome.tsx +++ b/app/components/cinematic/CinematicHome.tsx @@ -9,6 +9,7 @@ import type { import Hero from "../Hero"; import Projects from "../Projects"; import { SectionLoading } from "../SectionLoading"; +import ScrollStory from "../story/ScrollStory"; import CinematicSection from "./CinematicSection"; const About = dynamic(() => import("../About"), { @@ -49,7 +50,7 @@ export default function CinematicHome({ skillCategories: PortfolioSkillCategory[]; }) { return ( -
+
@@ -58,6 +59,16 @@ export default function CinematicHome({
+ {/* Reel scrollytelling: timeline scrubbed pelo scroll (estilo product-page) */} + acc + cat.skills.length, + 0, + )} + /> + diff --git a/app/components/story/ScrollStory.test.tsx b/app/components/story/ScrollStory.test.tsx new file mode 100644 index 0000000..5e9537a --- /dev/null +++ b/app/components/story/ScrollStory.test.tsx @@ -0,0 +1,80 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import ScrollStory from "./ScrollStory"; + +// gsap é carregado via import() dinâmico dentro do effect; o contrato testado +// aqui é markup do reel (cenas/HUD/alvos), não a timeline — que é verificada +// no browser via probe CDP. Mock leve mantém a suíte em jsdom. +jest.mock("gsap", () => { + const chain = { set: jest.fn(), fromTo: jest.fn(), to: jest.fn() }; + Object.assign(chain, { + set: jest.fn(() => chain), + fromTo: jest.fn(() => chain), + to: jest.fn(() => chain), + }); + return { + gsap: { + registerPlugin: jest.fn(), + set: jest.fn(), + timeline: jest.fn(() => chain), + ticker: { add: jest.fn(), remove: jest.fn(), lagSmoothing: jest.fn() }, + utils: { toArray: jest.fn(() => []) }, + context: jest.fn((fn: () => void) => { + fn(); + return { revert: jest.fn() }; + }), + }, + }; +}); +jest.mock("gsap/ScrollTrigger", () => ({ + ScrollTrigger: { getAll: jest.fn(() => []) }, +})); + +const props = { projectsCount: 5, experiencesCount: 6, skillsCount: 18 }; + +function mockMatchMedia(matches: boolean) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} + +describe("ScrollStory", () => { + afterEach(() => { + jest.restoreAllMocks(); + mockMatchMedia(false); + }); + + it("renderiza fallback empilhado com conteúdo real com reduced motion", () => { + mockMatchMedia(true); + const { container } = render(); + // sem palco scrub: capítulos legíveis no fluxo, números reais do portfólio + expect(container.querySelector("[data-story]")).not.toBeInTheDocument(); + expect(container).toHaveTextContent("01 — Visão geral"); + expect(container).toHaveTextContent("04 — Próximo ciclo"); + expect(container).toHaveTextContent("5"); + expect(screen.getAllByText(/sistemas ativos no portfólio/)).toHaveLength(1); + }); + + it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => { + mockMatchMedia(false); + const { container } = render(); + await waitFor(() => + expect(container.querySelector("[data-story]")).toBeInTheDocument(), + ); + expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4); + expect(container.querySelector("[data-story-index]")).toHaveTextContent("01"); + expect(container.querySelector("[data-story-bar]")).toBeInTheDocument(); + // count-up é alimentado pela timeline: markup carrega o alvo real + const num = container.querySelector("[data-story-num]"); + expect(num?.getAttribute("data-story-num-to")).toBe("5"); + }); +}); diff --git a/app/components/story/ScrollStory.tsx b/app/components/story/ScrollStory.tsx new file mode 100644 index 0000000..aa465c9 --- /dev/null +++ b/app/components/story/ScrollStory.tsx @@ -0,0 +1,377 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, useRef, useState } from "react"; +import { buildWhatsAppUrl } from "@/lib/whatsapp"; + +const StoryStage3D = dynamic(() => import("./StoryStage3D"), { ssr: false }); + +type Chapter = { + kicker: string; + lines: string[]; + body?: string; + stats?: Array<{ value: number; suffix: string; label: string }>; + cta?: boolean; +}; + +/** + * Reel scrollytelling da home: palco sticky de 100dvh dirigido por uma única + * timeline GSAP com scrub — o scroll é o controle de play/rebobinar. + * Sem JS ou com prefers-reduced-motion: capítulos renderizam empilhados, + * inteiros e legíveis (mesma regra de fail-soft do hero). + */ +export default function ScrollStory({ + projectsCount, + experiencesCount, + skillsCount, +}: { + projectsCount: number; + experiencesCount: number; + skillsCount: number; +}) { + const chapters: Chapter[] = [ + { + kicker: "01 — Visão geral", + lines: ["Software que opera.", "Não só roda."], + body: "Role para rebobinar a engenharia por trás de cada sistema — a mesma página, agora em tomada contínua.", + }, + { + kicker: "02 — Arquitetura", + lines: ["Da borda", "ao painel."], + body: "Dispositivo, integração, API e interface girando juntos: contratos claros entre camada física e produto.", + }, + { + kicker: "03 — Em produção", + lines: ["Entrega com", "critério real."], + stats: [ + { value: projectsCount, suffix: "", label: "sistemas ativos no portfólio" }, + { value: experiencesCount, suffix: "", label: "ciclos de produto entregues" }, + { value: skillsCount, suffix: "", label: "tecnologias em operação" }, + ], + }, + { + kicker: "04 — Próximo ciclo", + lines: ["Vamos girar o", "seu próximo sistema."], + cta: true, + }, + ]; + + const sectionRef = useRef(null); + const progressRef = useRef(0); + const [engine, setEngine] = useState(false); + const [webgl, setWebgl] = useState(false); + + useEffect(() => { + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) return undefined; + setEngine(true); + setWebgl( + !window.matchMedia("(pointer: coarse)").matches && window.innerWidth >= 768, + ); + return undefined; + }, []); + + useEffect(() => { + if (!engine) return undefined; + const el = sectionRef.current; + if (!el) return undefined; + + let cleanup = () => {}; + let cancelled = false; + + const setup = async () => { + const [{ gsap }, { ScrollTrigger }] = await Promise.all([ + import("gsap"), + import("gsap/ScrollTrigger"), + ]); + if (cancelled) return; + gsap.registerPlugin(ScrollTrigger); + + const ctx = gsap.context(() => { + const scenes = gsap.utils.toArray("[data-story-scene]"); + const bar = el.querySelector("[data-story-bar]"); + const indexEl = el.querySelector("[data-story-index]"); + const hint = el.querySelector("[data-story-hint]"); + + gsap.set(scenes.slice(1), { autoAlpha: 0 }); + gsap.set(scenes, { yPercent: 0 }); + + const tl = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: el, + start: "top top", + end: "bottom bottom", + scrub: 0.85, + onUpdate: (self) => { + progressRef.current = self.progress; + if (indexEl) { + const i = Math.min( + scenes.length, + Math.floor(self.progress * scenes.length) + 1, + ); + indexEl.textContent = String(i).padStart(2, "0"); + } + }, + }, + }); + + const span = 1 / scenes.length; + scenes.forEach((scene, i) => { + const at = i * span; + const inner = scene.querySelectorAll("[data-story-line]"); + const kicker = scene.querySelector("[data-story-kicker]"); + const body = scene.querySelectorAll("[data-story-body]"); + const stats = scene.querySelectorAll("[data-story-stat]"); + + tl.fromTo( + inner, + { yPercent: 118 }, + { yPercent: 0, duration: span * 0.34, stagger: span * 0.05 }, + at + span * 0.02, + ); + if (kicker) + tl.fromTo( + kicker, + { autoAlpha: 0, y: 10 }, + { autoAlpha: 1, y: 0, duration: span * 0.16 }, + at, + ); + if (body.length) + tl.fromTo( + body, + { autoAlpha: 0, y: 22 }, + { autoAlpha: 1, y: 0, duration: span * 0.22, stagger: span * 0.04 }, + at + span * 0.18, + ); + if (stats.length) + tl.fromTo( + stats, + { autoAlpha: 0, y: 26, filter: "blur(6px)" }, + { + autoAlpha: 1, + y: 0, + filter: "blur(0px)", + duration: span * 0.24, + stagger: span * 0.05, + }, + at + span * 0.2, + ); + // entrada da cena + saída — janelas sobrepostas p/ crossfade contínuo + tl.fromTo( + scene, + { autoAlpha: 0 }, + { autoAlpha: 1, duration: span * 0.18 }, + Math.max(0, at - span * 0.08), + ); + if (i < scenes.length - 1) + tl.to(scene, { autoAlpha: 0, yPercent: -5, duration: span * 0.18 }, at + span * 0.8); + + if (stats.length) { + stats.forEach((statEl) => { + const numEl = statEl.querySelector("[data-story-num]"); + if (!numEl) return; + const target = Number(numEl.dataset.storyNumTo ?? "0"); + const counter = { v: 0 }; + tl.to( + counter, + { + v: target, + duration: span * 0.3, + ease: "power2.out", + onUpdate: () => { + numEl.textContent = String(Math.round(counter.v)); + }, + }, + at + span * 0.22, + ); + }); + } + }); + + if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0); + if (hint) tl.to(hint, { autoAlpha: 0, duration: span * 0.1 }, span * 0.12); + }, el); + + cleanup = () => ctx.revert(); + }; + + void setup(); + return () => { + cancelled = true; + cleanup(); + }; + }, [engine]); + + // ---- Fallback estático: capítulos empilhados, conteúdo inteiro ---- + if (!engine) { + return ( +
+
+
+ O reel +
+ {chapters.map((ch) => ( +
+
+ {ch.kicker} +
+

+ {ch.lines.join(" ")} +

+ {ch.body && ( +

+ {ch.body} +

+ )} + {ch.stats && ( +
    + {ch.stats.map((s) => ( +
  • +
    + {s.value} + {s.suffix} +
    +
    {s.label}
    +
  • + ))} +
+ )} + {ch.cta && ( + + Conversar agora + + )} +
+ ))} +
+
+ ); + } + + // ---- Modo reel: palco sticky com cenas em crossfade ---- + return ( +
+
+ {/* fundo: gradiente radial + brilho do WebGL */} +
+ {webgl && ( +
+ +
+ )} + {/* legibilidade do texto sobre a cena */} +
+ + {/* cenas */} +
+ {chapters.map((ch, i) => ( +
+
+
+
+ {ch.kicker} +
+

+ {ch.lines.map((line) => ( + + + {line} + + + ))} +

+ {ch.body && ( +

+ {ch.body} +

+ )} + {ch.stats && ( +
    + {ch.stats.map((s) => ( +
  • +
    + + 0 + + {s.suffix} +
    +
    + {s.label} +
    +
  • + ))} +
+ )} + {ch.cta && ( + + Conversar agora + + )} +
+
+
+ ))} +
+ + {/* HUD: índice de capítulo + barra de progresso do reel */} +
+
+ + 01 + + / 0{chapters.length} +
+
+
+
+
+
+ ↕ scroll = play / rewind +
+
+
+ ); +} diff --git a/app/components/story/StoryStage3D.tsx b/app/components/story/StoryStage3D.tsx new file mode 100644 index 0000000..0fc0581 --- /dev/null +++ b/app/components/story/StoryStage3D.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { Canvas, useFrame } from "@react-three/fiber"; +import { Environment, Lightformer, RoundedBox, Sparkles } from "@react-three/drei"; +import { useRef } from "react"; +import * as THREE from "three"; + +type ProgressRef = { current: number }; + +/** + * Palco WebGL do scrollytelling: um "dispositivo" 3D procedural que gira, + * inclina e aproxima conforme o progresso do scroll (ref compartilhada — sem + * re-render do React por frame). O lerp interno suaviza o scrub em touch. + * Composição estilo product-page: dispositivo no terço direito, texto à esquerda. + */ +function DeviceRig({ progress }: { progress: ProgressRef }) { + const rig = useRef(null); + const halo = useRef(null); + const screenMat = useRef(null); + const glow = useRef(null); + const smooth = useRef(0); + + useFrame((_, dt) => { + smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7); + const p = smooth.current; + const g = rig.current; + 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) { + halo.current.rotation.z += dt * 0.25; + const m = halo.current.material as THREE.MeshBasicMaterial; + m.opacity = 0.1 + p * 0.08; + } + if (screenMat.current) { + // tela "liga" cena a cena: 4 pulsos ao longo do reel + screenMat.current.emissiveIntensity = + 0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5; + } + if (glow.current) { + glow.current.intensity = 2.2 + Math.sin(p * Math.PI * 4) * 1.4; + } + }); + + return ( + + {/* corpo do dispositivo */} + + + + {/* borda luminosa (bezel slash) */} + + + + {/* tela emissiva */} + + + + + {/* halo orbital */} + + + + + + + ); +} + +function CameraRig({ progress }: { progress: ProgressRef }) { + const smooth = useRef(0); + useFrame(({ camera }, dt) => { + smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7); + const p = smooth.current; + // leve steadicam: 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.y = -0.3 + p * 0.6; + camera.lookAt(0, 0, 0); + }); + return null; +} + +export default function StoryStage3D({ progress }: { progress: ProgressRef }) { + return ( + + + + + + + + + + + + + ); +}