diff --git a/CREDITS.md b/CREDITS.md index 56f72e6..448532e 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,19 +1,9 @@ # 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. - ## 3D model — “Laptop” - Author: **Kenney** (https://kenney.nl) - Source: https://poly.pizza/m/GnbwSUiVty -- License: **CC0 1.0 Universal (Public Domain)** +- License: **CC0 1.0 Universal (Public Domain)** (https://creativecommons.org/publicdomain/zero/1.0/) - Used in: `app/components/story/NotebookStage3D.tsx`, file at `public/models/laptop-kenney.glb`. - Modifications: the downloaded open-laptop geometry was split into @@ -23,4 +13,4 @@ ## 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. + on-screen content inside the notebook browser surface. diff --git a/app/components/cinematic/CinematicHome.test.tsx b/app/components/cinematic/CinematicHome.test.tsx new file mode 100644 index 0000000..35809ef --- /dev/null +++ b/app/components/cinematic/CinematicHome.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import CinematicHome from "./CinematicHome"; + +jest.mock("next/dynamic", () => { + const labels = [ + "About", + "Skills", + "Experience", + "WorkModalities", + "Contact", + "LighthouseMetrics", + "Footer", + "ScrollVelocityText", + "SectionDivider", + ]; + let index = 0; + + return () => { + const label = labels[index++] ?? "DynamicSection"; + return function DynamicMock() { + return
{label}
; + }; + }; +}); + +jest.mock("../Hero", () => () =>
); +jest.mock("../Projects", () => () =>
); +jest.mock("../story/ReelDirector", () => () => ( +
+)); +jest.mock("../story/NotebookStory", () => () => ( +
+)); +jest.mock( + "./CinematicSection", + () => + ({ children }: { children: ReactNode }) => <>{children}, +); + +describe("CinematicHome", () => { + it("uses one notebook story and keeps About after it", () => { + render( + , + ); + + const story = screen.getByTestId("notebook-story"); + const about = screen.getByTestId("dynamic-About"); + + expect(story).toBeInTheDocument(); + expect(screen.queryByTestId("print-reel")).not.toBeInTheDocument(); + expect(screen.queryByTestId("scroll-story")).not.toBeInTheDocument(); + expect( + Boolean( + story.compareDocumentPosition(about) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + ).toBe(true); + }); +}); diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx index 5d45356..5af1fec 100644 --- a/app/components/cinematic/CinematicHome.tsx +++ b/app/components/cinematic/CinematicHome.tsx @@ -9,9 +9,8 @@ import type { import Hero from "../Hero"; import Projects from "../Projects"; import { SectionLoading } from "../SectionLoading"; -import ScrollStory from "../story/ScrollStory"; +import NotebookStory from "../story/NotebookStory"; import ReelDirector from "../story/ReelDirector"; -import PrintReel from "../story/PrintReel"; import CinematicSection from "./CinematicSection"; const About = dynamic(() => import("../About"), { @@ -62,18 +61,7 @@ export default function CinematicHome({
- {/* Reel scrollytelling: timeline scrubbed pelo scroll (estilo product-page) */} - acc + cat.skills.length, - 0, - )} - /> - - {/* Palco 2: filmstrip horizontal pinado com prints reais dos deployments */} - + diff --git a/app/components/story/NotebookStage3D.test.tsx b/app/components/story/NotebookStage3D.test.tsx new file mode 100644 index 0000000..70a8e49 --- /dev/null +++ b/app/components/story/NotebookStage3D.test.tsx @@ -0,0 +1,86 @@ +import { useGLTF } from "@react-three/drei"; +import { render, screen } from "@testing-library/react"; +import * as THREE from "three"; +import { portfolio } from "@/lib/portfolio-data"; +import NotebookStage3D, { + NOTEBOOK_MODEL_URL, + type NotebookStage3DProps, +} from "./NotebookStage3D"; +import { selectNotebookProjects } from "./notebookProjects"; + +jest.mock("@react-three/fiber", () => ({ + Canvas: ({ + children, + frameloop, + }: { + children: React.ReactNode; + frameloop: string; + }) => ( +
+ {children} +
+ ), + useFrame: jest.fn(), +})); + +jest.mock("@react-three/drei", () => ({ + Html: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + useGLTF: jest.fn(() => ({ scene: new THREE.Group() })), +})); + +const mockedUseGLTF = jest.mocked(useGLTF); +const projects = selectNotebookProjects(portfolio.projects); +const props: NotebookStage3DProps = { + activeIndex: 0, + isVisible: true, + onSelectTab: jest.fn(), + progress: { current: 0.56 }, + projects, +}; + +describe("NotebookStage3D", () => { + beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + mockedUseGLTF.mockImplementation((() => ({ + scene: new THREE.Group(), + })) as never); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders a single stage using the local model and browser surface", () => { + render(); + + expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(); + expect(screen.getByTestId("mock-canvas")).toBeInTheDocument(); + expect(screen.getByTestId("notebook-browser")).toBeInTheDocument(); + expect(mockedUseGLTF).toHaveBeenCalledWith(NOTEBOOK_MODEL_URL); + }); + + it("pauses the renderer when the stage is outside the viewport", () => { + render(); + + expect(screen.getByTestId("mock-canvas")).toHaveAttribute( + "data-frameloop", + "never", + ); + }); + + it("returns usable browser content when the GLB boundary fails", () => { + const error = new Error("GLB failed"); + mockedUseGLTF.mockImplementation(() => { + throw error; + }); + render(); + + expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /abrir projeto/i }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/story/NotebookStage3D.tsx b/app/components/story/NotebookStage3D.tsx new file mode 100644 index 0000000..a879e0a --- /dev/null +++ b/app/components/story/NotebookStage3D.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { Html, useGLTF } from "@react-three/drei"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { + Component, + type ReactNode, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import * as THREE from "three"; +import NotebookBrowser from "./NotebookBrowser"; +import NotebookFallback from "./NotebookFallback"; +import type { NotebookProject } from "./notebookProjects"; +import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline"; + +export const NOTEBOOK_MODEL_URL = "/models/laptop-kenney.glb"; + +type ProgressRef = { current: number }; + +export type NotebookStage3DProps = { + progress: ProgressRef; + projects: NotebookProject[]; + activeIndex: number; + onSelectTab: (index: number) => void; + isVisible: boolean; +}; + +type NotebookSceneProps = Omit; + +type MaterialMap = { + body: THREE.MeshStandardMaterial; + frame: THREE.MeshStandardMaterial; + screen: THREE.MeshStandardMaterial; +}; + +const MODEL_SCALE = 3.5; +const OPEN_LID_ANGLE = 112; + +function createMaterials(): MaterialMap { + return { + body: new THREE.MeshStandardMaterial({ + color: "#102020", + metalness: 0.78, + roughness: 0.3, + transparent: true, + }), + frame: new THREE.MeshStandardMaterial({ + color: "#294345", + metalness: 0.64, + roughness: 0.38, + transparent: true, + }), + screen: new THREE.MeshStandardMaterial({ + color: "#a8b8b8", + metalness: 0.05, + roughness: 0.72, + transparent: true, + }), + }; +} + +function cloneNotebookScene(scene: THREE.Object3D): { + root: THREE.Object3D; + materials: MaterialMap; +} { + const root = scene.clone(true); + const materials = createMaterials(); + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + + object.geometry = object.geometry.clone(); + const sourceMaterial = Array.isArray(object.material) + ? object.material[0] + : object.material; + const materialName = sourceMaterial?.name ?? ""; + object.material = + materialName === "metal" + ? materials.screen + : materialName === "metalMedium" + ? materials.frame + : materials.body; + object.frustumCulled = false; + }); + + return { root, materials }; +} + +function updateMaterialOpacity(materials: MaterialMap, opacity: number) { + materials.body.opacity = opacity; + materials.frame.opacity = opacity; + materials.screen.opacity = opacity; +} + +function disposeNotebookScene(root: THREE.Object3D, materials: MaterialMap) { + root.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return; + object.geometry.dispose(); + }); + materials.body.dispose(); + materials.frame.dispose(); + materials.screen.dispose(); +} + +function LoadingNotebook() { + return ( + + + + + + + ); +} + +function ScreenSurface({ + frame, + projects, + activeIndex, + onSelectTab, +}: { + frame: NotebookFrame; + projects: NotebookProject[]; + activeIndex: number; + onSelectTab: (index: number) => void; +}) { + return ( + +
+ +
+ + ); +} + +function NotebookScene({ + activeIndex, + onSelectTab, + progress, + projects, +}: NotebookSceneProps) { + const { scene } = useGLTF(NOTEBOOK_MODEL_URL); + const rig = useRef(null); + const lidRig = useRef(null); + const smoothProgress = useRef(progress.current); + const elapsedForDom = useRef(0); + const [frame, setFrame] = useState(() => + getNotebookFrame(progress.current, projects.length), + ); + const cloned = useMemo(() => cloneNotebookScene(scene), [scene]); + const lid = cloned.root.getObjectByName("Laptop_Lid"); + const base = cloned.root.getObjectByName("Laptop_Base"); + + useFrame((_, delta) => { + smoothProgress.current += + (progress.current - smoothProgress.current) * Math.min(1, delta * 8); + const nextFrame = getNotebookFrame(smoothProgress.current, projects.length); + + if (rig.current) { + rig.current.position.set(0.46, -0.34, 0); + rig.current.scale.setScalar(MODEL_SCALE); + } + if (lidRig.current) { + lidRig.current.rotation.x = THREE.MathUtils.degToRad( + nextFrame.lidAngle - OPEN_LID_ANGLE, + ); + } + updateMaterialOpacity(cloned.materials, nextFrame.deviceOpacity); + + elapsedForDom.current += delta; + if (elapsedForDom.current >= 1 / 24) { + elapsedForDom.current = 0; + setFrame(nextFrame); + } + }); + + useEffect( + () => () => disposeNotebookScene(cloned.root, cloned.materials), + [cloned], + ); + + return ( + + + + + {base && lid ? ( + <> + + + + + + + ) : ( + <> + + + + + + )} + + ); +} + +class NotebookStageBoundary extends Component< + NotebookStage3DProps, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render(): ReactNode { + if (this.state.failed) { + return ( + + ); + } + + return ( + + }> + + + + ); + } +} + +export default function NotebookStage3D(props: NotebookStage3DProps) { + return ( +
+ +
+ ); +} diff --git a/app/components/story/NotebookStory.test.tsx b/app/components/story/NotebookStory.test.tsx new file mode 100644 index 0000000..38e6a8b --- /dev/null +++ b/app/components/story/NotebookStory.test.tsx @@ -0,0 +1,164 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { portfolio } from "@/lib/portfolio-data"; +import NotebookStory, { + canUseNotebook3D, + getNotebookSeekProgress, +} from "./NotebookStory"; + +jest.mock("next/dynamic", () => () => { + return function DynamicNotebookStage() { + return
; + }; +}); + +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 = portfolio.projects; + +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(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + })); +} + +describe("NotebookStory", () => { + beforeEach(() => { + mockMatchMedia(); + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1280, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders the static fallback when reduced motion is enabled", async () => { + mockMatchMedia({ reduced: true }); + + render(); + + expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), + ); + }); + + it("does not initialize the 3D stage on coarse pointers", async () => { + mockMatchMedia({ coarse: true }); + + render(); + + expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), + ); + }); + + it("initializes the 3D stage only on a desktop hover-capable viewport", async () => { + render(); + + await waitFor(() => + expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(), + ); + }); + it("keeps the static fallback when no mapped projects are available", async () => { + mockMatchMedia(); + + render(); + + expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), + ); + }); + + it("requires desktop hover input without reduced motion", () => { + const matchMedia = (query: string) => + ({ + matches: query === "(hover: hover)", + }) as MediaQueryList; + + expect(canUseNotebook3D({ innerWidth: 1280, matchMedia })).toBe(true); + expect(canUseNotebook3D({ innerWidth: 1024, matchMedia })).toBe(false); + expect( + canUseNotebook3D({ + innerWidth: 1280, + 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(); + + expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument(); + expect( + screen.queryByRole("tab", { name: /Twins Lab/i }), + ).not.toBeInTheDocument(); + }); + + it("maps tab seeks inside the browse window without a second scroll engine", () => { + const first = getNotebookSeekProgress(0, 4); + const middle = getNotebookSeekProgress(2, 4); + const last = getNotebookSeekProgress(3, 4); + + expect(first).toBeGreaterThanOrEqual(0.44); + expect(first).toBeLessThan(0.78); + expect(middle).toBeGreaterThan(first); + expect(last).toBeLessThan(0.78); + expect(getNotebookSeekProgress(99, 4)).toBeLessThan(0.78); + }); + + it("does not register a global scroll listener", () => { + const addEventListener = jest.spyOn(window, "addEventListener"); + mockMatchMedia({ reduced: true }); + + render(); + + expect( + addEventListener.mock.calls.some(([type]) => type === "scroll"), + ).toBe(false); + }); +}); diff --git a/app/components/story/NotebookStory.tsx b/app/components/story/NotebookStory.tsx new file mode 100644 index 0000000..b56b84f --- /dev/null +++ b/app/components/story/NotebookStory.tsx @@ -0,0 +1,218 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { PortfolioProject } from "@/lib/portfolio-data"; +import NotebookFallback from "./NotebookFallback"; +import { selectNotebookProjects } from "./notebookProjects"; +import { getNotebookFrame } from "./notebookTimeline"; + +const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), { + ssr: false, +}); + +const BROWSE_START = 0.44; +const BROWSE_END = 0.78; + +type CapabilityWindow = Pick; + +export type NotebookStoryProps = { + projects: PortfolioProject[]; +}; + +export function canUseNotebook3D(view: CapabilityWindow): boolean { + if (typeof view.matchMedia !== "function") return false; + return ( + view.innerWidth >= 1025 && + !view.matchMedia("(prefers-reduced-motion: reduce)").matches && + !view.matchMedia("(pointer: coarse)").matches && + view.matchMedia("(hover: hover)").matches + ); +} + +export function getNotebookSeekProgress( + index: number, + tabCount: number, +): number { + const count = Math.max(0, Math.floor(tabCount)); + if (count < 1) return BROWSE_START; + const safeIndex = Math.min(Math.max(Math.floor(index), 0), count - 1); + const slot = (BROWSE_END - BROWSE_START) / count; + return BROWSE_START + slot * (safeIndex + 0.5); +} + +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 }: NotebookStoryProps) { + const notebookProjects = useMemo( + () => selectNotebookProjects(projects), + [projects], + ); + const sectionRef = useRef(null); + const progressRef = useRef(0); + const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1); + const [engine, setEngine] = useState(false); + const [isVisible, setIsVisible] = useState(true); + const [activeIndex, setActiveIndex] = useState(activeIndexRef.current); + useEffect(() => { + setEngine(notebookProjects.length > 0 && canUseNotebook3D(window)); + }, [notebookProjects.length]); + + useEffect(() => { + const section = sectionRef.current; + if (!engine || !section || !("IntersectionObserver" in window)) { + return undefined; + } + + const observer = new IntersectionObserver( + ([entry]) => setIsVisible(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 timeline = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: sectionRoot, + start: "top top", + end: "bottom bottom", + scrub: 0.85, + invalidateOnRefresh: true, + onUpdate: (self) => { + progressRef.current = self.progress; + const nextIndex = getNotebookFrame( + self.progress, + notebookProjects.length, + ).activeTabIndex; + if (nextIndex >= 0 && nextIndex !== activeIndexRef.current) { + activeIndexRef.current = nextIndex; + setActiveIndex(nextIndex); + } + }, + }, + }); + timeline.to(driver, { duration: 1, value: 1 }); + }, sectionRoot); + cleanup = () => context.revert(); + } + + void setupTimeline(); + return () => { + cancelled = true; + cleanup(); + }; + }, [engine, notebookProjects.length]); + + const seekToTab = useCallback( + (index: number) => { + const section = sectionRef.current; + if (!section || notebookProjects.length < 1) return; + + const rect = section.getBoundingClientRect(); + const sectionTop = (window.scrollY || window.pageYOffset) + rect.top; + const runwayDistance = Math.max( + section.offsetHeight - window.innerHeight, + 0, + ); + const target = + sectionTop + + runwayDistance * + getNotebookSeekProgress(index, notebookProjects.length); + window.scrollTo({ top: target, behavior: "smooth" }); + }, + [notebookProjects.length], + ); + + const handleSelectTab = useCallback( + (index: number) => { + const safeIndex = clampIndex(index, notebookProjects.length); + if (safeIndex < 0) return; + activeIndexRef.current = safeIndex; + setActiveIndex(safeIndex); + seekToTab(safeIndex); + }, + [notebookProjects.length, seekToTab], + ); + + if (!engine) { + return ( +
+
+
+ O notebook +

Software que opera.

+

+ Uma seleção navegável de sistemas reais, com suas interfaces e + tecnologias em produção. +

+
+ +
+
+ ); + } + + return ( +
+
+
+
+ O notebook +

Software que opera.

+

+ Role para abrir a engenharia por trás de cada entrega. O scroll é + o controle de play e rebobinar. +

+ + {String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "} + {String(notebookProjects.length).padStart(2, "0")} + +
+ +
+
+
+ ); +} diff --git a/app/components/story/PrintReel.tsx b/app/components/story/PrintReel.tsx deleted file mode 100644 index 16b2e6b..0000000 --- a/app/components/story/PrintReel.tsx +++ /dev/null @@ -1,290 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; - -/** - * Palco 2 do estilo product-page: filmstrip horizontal dirigido pelo scroll. - * Arquitetura: runway 280vh + palco `position: sticky` (mesma técnica provada - * no reel do hero — sem GSAP pin, sem wrapper de flex). O scrub move a faixa - * da direita para a esquerda; quando a faixa termina, o sticky solta e o - * scroll continua para baixo normalmente. Role para cima = rebobina. - * - * Sem JS / reduced motion: grid estático (fallback), como no ScrollStory. - */ - -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, - }, -]; - -const CARD_W = "min(80vw, 430px)"; - -export default function PrintReel() { - const sectionRef = useRef(null); - const [engine, setEngine] = useState(false); - - useEffect(() => { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) - return undefined; - setEngine(true); - return undefined; - }, []); - - useEffect(() => { - if (!engine) return undefined; - const section = sectionRef.current; - if (!section) 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 track = section.querySelector("[data-strip-track]"); - const idxEl = section.querySelector("[data-strip-index]"); - const bar = section.querySelector("[data-strip-bar]"); - if (!track) return; - - // distância = largura da faixa menos a viewport - const distance = () => - Math.max(0, track.scrollWidth - window.innerWidth); - - const tl = gsap.timeline({ - defaults: { ease: "none" }, - scrollTrigger: { - trigger: section, - start: "top top", - end: "bottom bottom", - scrub: 0.85, - invalidateOnRefresh: true, - }, - }); - tl.to(track, { x: () => -distance(), duration: 1 }); - if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0); - if (idxEl) { - tl.eventCallback("onUpdate", () => { - const p = (tl.scrollTrigger?.progress ?? 0) * PRINTS.length; - idxEl.textContent = String( - Math.min(PRINTS.length, Math.floor(p) + 1), - ).padStart(2, "0"); - }); - } - - // cada card "acende" ao entrar e esmaece ao passar (scrub, reversível) - const cards = Array.from( - section.querySelectorAll("[data-strip-card]"), - ); - const span = 1 / cards.length; - cards.forEach((card, i) => { - tl.fromTo( - card, - { opacity: 0.15, scale: 0.92, rotateY: -9 }, - { - opacity: 1, - scale: 1, - rotateY: 0, - transformPerspective: 1100, - duration: span * 0.5, - }, - Math.max(0, i * span - span * 0.12), - ); - if (i > 0) { - tl.to( - card, - { opacity: 0.35, scale: 0.94, duration: span * 0.4 }, - (i - 1) * span + span * 0.55, - ); - } - }); - }, section); - - cleanup = () => ctx.revert(); - }; - - void setup(); - return () => { - cancelled = true; - cleanup(); - }; - }, [engine]); - - // ---- Fallback estático (sem JS / reduced motion) ---- - if (!engine) { - return ( - - ); - } - - // ---- Modo reel: runway + sticky ---- - return ( - - ); -} diff --git a/app/components/story/ScrollStory.test.tsx b/app/components/story/ScrollStory.test.tsx deleted file mode 100644 index 5c076a9..0000000 --- a/app/components/story/ScrollStory.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 () => { - const { container } = render(); - await waitFor( - () => expect(container.querySelector("[data-story]")).toBeInTheDocument(), - { timeout: 10000 }, - ); - 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"); - }, 20000); -}); diff --git a/app/components/story/ScrollStory.tsx b/app/components/story/ScrollStory.tsx deleted file mode 100644 index aa465c9..0000000 --- a/app/components/story/ScrollStory.tsx +++ /dev/null @@ -1,377 +0,0 @@ -"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 deleted file mode 100644 index e5a293f..0000000 --- a/app/components/story/StoryStage3D.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client"; - -import { Canvas, useFrame } from "@react-three/fiber"; -import { - 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 { createPhoneScreen, type ScreenPainter } from "./phoneScreenTexture"; - -type ProgressRef = { current: number }; - -/** - * Palco WebGL do scrollytelling. - * Modelo 3D do aparelho: "Phone" by Quaternius — CC0 (Public Domain), via - * poly.pizza (https://poly.pizza/m/k2kgBepoMU). Creditado em CREDITS.md. - * 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 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(null); - const halo = useRef(null); - const smooth = useRef(0); - const [painter] = useState(() => createPhoneScreen()); - const texture = useMemo(() => { - const t = new THREE.CanvasTexture(painter.canvas); - t.colorSpace = THREE.SRGBColorSpace; - t.anisotropy = 4; - return t; - }, [painter]); - - // 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); - const p = smooth.current; - if (rig.current) motionRig(rig.current, p); - 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; - } - // pinta o mini-site ~4fps quando há mudança real (quantizada no painter) - const t = state.clock.elapsedTime * 1000; - if (painter.ready() && painter.paint(p, t)) { - texture.needsUpdate = true; - } - }); - - 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 ( - - - - - - - - - ); -} - -/** Fallback procedural (GLTF falhou / SwiftShader): rig de RoundedBox. */ -function ProceduralPhone({ progress }: { progress: ProgressRef }) { - const rig = useRef(null); - const screenMat = useRef(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 ( - - - - - - - - - - ); -} - -// 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 ; - return ; - } -} - -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; - // 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.y = -0.3 + p * 0.6; - camera.lookAt(0, 0, 0); - }); - return null; -} - -export default function StoryStage3D({ progress }: { progress: ProgressRef }) { - return ( - - - - - }> - - - - - - - - - - - ); -} diff --git a/app/components/story/phoneScreenTexture.ts b/app/components/story/phoneScreenTexture.ts deleted file mode 100644 index ccbae13..0000000 --- a/app/components/story/phoneScreenTexture.ts +++ /dev/null @@ -1,183 +0,0 @@ -// 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; diff --git a/app/globals.css b/app/globals.css index 8af320e..6ba1d67 100644 --- a/app/globals.css +++ b/app/globals.css @@ -835,13 +835,14 @@ textarea { .notebook-story-runway { position: relative; - min-height: 100dvh; + min-height: 520vh; } .notebook-story-sticky { position: sticky; top: 0; display: grid; + height: 100dvh; min-height: 100dvh; place-items: center; padding: clamp(40px, 7vw, 96px) clamp(20px, 6vw, 80px); @@ -904,6 +905,30 @@ textarea { width: 100%; height: 100%; } +.notebook-story-static-content { + width: min(1080px, 100%); + margin: 0 auto; + padding: clamp(48px, 9vw, 112px) clamp(20px, 6vw, 80px); +} + +.notebook-story-static-content > .notebook-fallback { + width: 100%; + margin: 0; + padding: 0; +} + +.notebook-screen-surface { + width: min(660px, 68vw); + min-width: 280px; + overflow: hidden; + border-radius: 2px; + transform: translateZ(0); +} + +.notebook-screen-surface .notebook-browser { + width: 100%; + box-shadow: none; +} .notebook-fallback { width: min(1080px, 100%); diff --git a/public/models/phone-quaternius.glb b/public/models/phone-quaternius.glb deleted file mode 100644 index 356c518..0000000 Binary files a/public/models/phone-quaternius.glb and /dev/null differ