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 (
-
-
-
- No ar, rodando
-
-
- Prints ao vivo dos sistemas em produção
-
-
-
-
- );
- }
-
- // ---- Modo reel: runway + sticky ----
- return (
-
-
- {/* cabeçalho do palco */}
-
-
-
- No ar, rodando
-
-
- Prints ao vivo da produção
-
-
-
- ← role para percorrer →
-
-
-
- {/* faixa */}
-
-
- {/* HUD */}
-
-
- 01
-
-
- / 0{PRINTS.length}
-
-
-
-
-
- );
-}
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.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