diff --git a/CREDITS.md b/CREDITS.md
new file mode 100644
index 0000000..ed7fb0e
--- /dev/null
+++ b/CREDITS.md
@@ -0,0 +1,16 @@
+# Credits / Third-party assets
+
+## 3D model — “Phone”
+- Author: **Quaternius** (https://quaternius.com)
+- Source: https://poly.pizza/m/k2kgBepoMU
+- License: **CC0 1.0 Universal (Public Domain)**
+- Used in: `app/components/story/StoryStage3D.tsx` (hero scrollytelling reel),
+ file at `public/models/phone-quaternius.glb`.
+- Modifications: material overrides (body/frame metallic dark; the model's
+ `Black` surface renders a live CanvasTexture of the portfolio site). CC0 does
+ not require attribution; credit given anyway.
+
+## Project screenshots
+- `public/assets/prints/*.png` are captures taken with headless Chrome from
+ production deployments owned by the site author (evandro.dev.br), used as
+ on-screen content inside the 3D phone and section animations.
diff --git a/app/components/About.tsx b/app/components/About.tsx
index c8c5c0d..b41d0f2 100644
--- a/app/components/About.tsx
+++ b/app/components/About.tsx
@@ -91,7 +91,7 @@ export default function About() {
description="Atuo na interseção entre arquitetura de software, integrações de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
disableChildrenAnimation
aside={
-
+
Garuva, SC | Remote worldwide
@@ -120,7 +120,9 @@ export default function About() {
{step.label}
{step.title}
-
{step.content}
+
+ {step.content}
+
))}
@@ -141,13 +143,13 @@ export default function About() {
Technical profile
Base técnica e postura de entrega
-
+
Sou engenheiro da computação e desenvolvedor de software full-stack,
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
separadas: hardware, servicos, operacao e interface. Meu trabalho e
dar forma clara ao que costuma nascer fragmentado.
-
+
{PROFILE_POINTS.map((point) => (
-
+
- Availability
+
+ Availability
+
- Resposta inicial em ate 24h
-
+
+ Resposta inicial em ate 24h
+
+
Ideal para discovery tecnico, revisao de arquitetura, rescue de
projeto, performance ou construcao de produto com ownership real.
-
+
WhatsApp: {whatsAppDisplay}
E-mail: evandrofonsecajunior@gmail.com
Base: Garuva, Santa Catarina | atuacao remota
-
+
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
conversa tecnica objetiva.
diff --git a/app/components/Experience.tsx b/app/components/Experience.tsx
index 0d354ad..24982c7 100644
--- a/app/components/Experience.tsx
+++ b/app/components/Experience.tsx
@@ -79,7 +79,7 @@ export default function Experience({
{exp.period}
-
{exp.title}
+
{exp.title}
{exp.company} • {exp.type}
@@ -87,7 +87,7 @@ export default function Experience({
{exp.location}
-
{exp.description}
+
{exp.description}
{exp.achievements?.map((achievement) => (
@@ -95,7 +95,7 @@ export default function Experience({
))}
-
+
{exp.technologies?.slice(0, 6).map((tech) => (
{tech}
diff --git a/app/components/LighthouseMetrics.tsx b/app/components/LighthouseMetrics.tsx
index c18d42e..acbe717 100644
--- a/app/components/LighthouseMetrics.tsx
+++ b/app/components/LighthouseMetrics.tsx
@@ -43,7 +43,10 @@ export default function LighthouseMetrics() {
dark
disableChildrenAnimation={false}
>
-
+
{METRICS.map((metric, i) => (
{/* Content Details */}
-
+
{metric.label}
diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx
index 8b57e6e..6b17bc2 100644
--- a/app/components/Projects.tsx
+++ b/app/components/Projects.tsx
@@ -93,7 +93,7 @@ export default function Projects({
{project.description}
-
+
{project.technologies.slice(0, 5).map((tech) => (
{tech}
@@ -101,7 +101,7 @@ export default function Projects({
))}
-
+
{project.githubUrl && (
-
+
- {category.category}
+
+ {category.category}
+
-
+
Ferramentas que uso para construir com clareza tecnica e ritmo de
entrega.
-
+
{category.skills.map((skill) => (
{skill}
diff --git a/app/components/WorkModalities.tsx b/app/components/WorkModalities.tsx
index e07bc27..b9bf7fc 100644
--- a/app/components/WorkModalities.tsx
+++ b/app/components/WorkModalities.tsx
@@ -128,11 +128,11 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
-
+
{tier.description}
-
+
{tier.deliverables.map((deliverable) => (
{deliverable}
@@ -202,10 +202,10 @@ export function WorkModalities() {
Páginas de serviço
-
+
Especialidades organizadas por intenção de busca
-
+
Se você chegou buscando uma stack, um tipo de consultoria ou um
contexto técnico específico, aqui está o caminho mais direto para
a página certa.
@@ -221,7 +221,7 @@ export function WorkModalities() {
-
+
{serviceHubLinks.map((service) => (
import("../About"), {
@@ -52,6 +54,7 @@ export default function CinematicHome({
return (
+
{/* Marquee divider after hero */}
@@ -69,6 +72,9 @@ export default function CinematicHome({
)}
/>
+ {/* Palco 2: filmstrip horizontal pinado com prints reais dos deployments */}
+
+
diff --git a/app/components/story/PrintReel.tsx b/app/components/story/PrintReel.tsx
new file mode 100644
index 0000000..1be896b
--- /dev/null
+++ b/app/components/story/PrintReel.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { DrawLine } from "../animations/DrawLine";
+import HorizontalScroll from "../cinematic/HorizontalScroll";
+import { Section } from "../Section";
+
+/**
+ * Palco extra estilo product-page: filmstrip horizontal PINNADO com prints
+ * reais capturados dos deployments em produção (public/assets/prints).
+ * O scroll vertical empurra a faixa na horizontal (scrub), cada card entra
+ * com rotateY/scale ligados ao mesmo trilho — sensação de carrossel de vídeo.
+ */
+
+const PRINTS = [
+ {
+ src: "/assets/prints/print-nutriscan.png",
+ name: "NutriScan",
+ desc: "PWA de dados nutricionais sobre Open Food Facts — busca, Nutri-Score e NOVA.",
+ url: "https://nutriscan.evandro.dev.br",
+ accent: true,
+ },
+ {
+ src: "/assets/prints/print-scplus.png",
+ name: "SCPLUS",
+ desc: "Educação e tecnologia sustentável em Garuva-SC; interface institucional viva.",
+ url: "https://scplus.evandro.dev.br",
+ accent: false,
+ },
+ {
+ src: "/assets/prints/print-sev.png",
+ name: "SEV Moda Íntima",
+ desc: "E-commerce em produção com checkout e catálogo — Next.js ponta a ponta.",
+ url: "https://sevmodaintima.com.br",
+ accent: true,
+ },
+ {
+ src: "/assets/prints/print-asservicos.png",
+ name: "AS Serviços",
+ desc: "Presença digital SSR para operação de serviços, pronta para crescer.",
+ url: "https://asservicos.com",
+ accent: false,
+ },
+];
+
+export default function PrintReel() {
+ return (
+
+ );
+}
diff --git a/app/components/story/ReelDirector.tsx b/app/components/story/ReelDirector.tsx
new file mode 100644
index 0000000..0798fb4
--- /dev/null
+++ b/app/components/story/ReelDirector.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+/**
+ * ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
+ *
+ * Contrato (declarativo, via atributos data-*):
+ * [data-reel="left|right|top|bottom|zoom|fade"]
+ * elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
+ * [data-reel-stagger="left|right|top|bottom|zoom|fade"]
+ * container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
+ * data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
+ * data-reel-distance="64" (opcional: px de deslocamento)
+ *
+ * Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
+ * prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
+ */
+
+type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
+
+const DISTANCE = 64;
+
+function fromVars(dir: Direction, dist: number) {
+ const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
+ if (dir === "left") return { ...base, x: -dist };
+ if (dir === "right") return { ...base, x: dist };
+ if (dir === "top") return { ...base, y: -dist * 0.75 };
+ if (dir === "bottom") return { ...base, y: dist };
+ if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
+ return { ...base, scale: 1.02 };
+}
+
+const TO_VARS = {
+ opacity: 1,
+ x: 0,
+ y: 0,
+ scale: 1,
+ filter: "blur(0px)",
+ ease: "none",
+};
+
+export default function ReelDirector() {
+ const alive = useRef(true);
+
+ useEffect(() => {
+ alive.current = true;
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
+ return undefined;
+
+ let teardown = () => {};
+
+ const boot = async () => {
+ const [{ gsap }, { ScrollTrigger }] = await Promise.all([
+ import("gsap"),
+ import("gsap/ScrollTrigger"),
+ ]);
+ if (!alive.current) return;
+ gsap.registerPlugin(ScrollTrigger);
+
+ const ctx = gsap.context(() => {
+ const scan = () => {
+ for (const el of document.querySelectorAll(
+ "[data-reel]:not([data-reel-init])",
+ )) {
+ el.dataset.reelInit = "1";
+ const dir = (el.dataset.reel || "bottom") as Direction;
+ const dist = Number(el.dataset.reelDistance || DISTANCE);
+ gsap.fromTo(el, fromVars(dir, dist), {
+ ...TO_VARS,
+ scrollTrigger: {
+ trigger: el,
+ start: el.dataset.reelStart || "top 88%",
+ end: el.dataset.reelEnd || "top 26%",
+ scrub: 0.6,
+ },
+ });
+ }
+ for (const container of document.querySelectorAll(
+ "[data-reel-stagger]:not([data-reel-init])",
+ )) {
+ container.dataset.reelInit = "1";
+ const dir = (container.dataset.reelStagger || "bottom") as Direction;
+ const dist = Number(container.dataset.reelDistance || DISTANCE);
+ const children = Array.from(container.children) as HTMLElement[];
+ if (children.length === 0) continue;
+ gsap.fromTo(
+ children,
+ fromVars(dir, dist),
+ {
+ ...TO_VARS,
+ stagger: { each: 0.09 },
+ scrollTrigger: {
+ trigger: container,
+ start: container.dataset.reelStart || "top 85%",
+ end: container.dataset.reelEnd || "top 25%",
+ scrub: 0.6,
+ },
+ },
+ );
+ }
+ };
+
+ scan();
+ // seções chegam via next/dynamic depois do boot — observa nós novos
+ const observer = new MutationObserver(() => scan());
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ teardown = () => {
+ observer.disconnect();
+ ctx.revert();
+ };
+ });
+ };
+
+ void boot();
+ return () => {
+ alive.current = false;
+ teardown();
+ };
+ }, []);
+
+ return null;
+}
diff --git a/app/components/story/ScrollStory.test.tsx b/app/components/story/ScrollStory.test.tsx
index 5e9537a..5c076a9 100644
--- a/app/components/story/ScrollStory.test.tsx
+++ b/app/components/story/ScrollStory.test.tsx
@@ -65,10 +65,10 @@ describe("ScrollStory", () => {
});
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
- mockMatchMedia(false);
const { container } = render( );
- await waitFor(() =>
- expect(container.querySelector("[data-story]")).toBeInTheDocument(),
+ 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");
@@ -76,5 +76,5 @@ describe("ScrollStory", () => {
// 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/StoryStage3D.tsx b/app/components/story/StoryStage3D.tsx
index 0fc0581..e5a293f 100644
--- a/app/components/story/StoryStage3D.tsx
+++ b/app/components/story/StoryStage3D.tsx
@@ -1,72 +1,160 @@
"use client";
import { Canvas, useFrame } from "@react-three/fiber";
-import { Environment, Lightformer, RoundedBox, Sparkles } from "@react-three/drei";
-import { useRef } from "react";
+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: um "dispositivo" 3D procedural que gira,
- * inclina e aproxima conforme o progresso do scroll (ref compartilhada — sem
- * re-render do React por frame). O lerp interno suaviza o scrub em touch.
- * Composição estilo product-page: dispositivo no terço direito, texto à esquerda.
+ * 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 DeviceRig({ progress }: { progress: ProgressRef }) {
+
+function motionRig(g: THREE.Object3D, p: number) {
+ // turntable contínuo: uma volta + extra, com inclinação de cena a cena
+ g.rotation.y = -0.7 + p * Math.PI * 2.2;
+ g.rotation.x = 0.08 + Math.sin(p * Math.PI) * 0.22;
+ g.rotation.z = Math.sin(p * Math.PI * 2) * 0.05;
+ g.position.x = 1.4;
+ g.position.y = Math.sin(p * Math.PI * 2) * 0.2;
+ g.scale.setScalar(0.8 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.16);
+}
+
+function ScreenPhone({ progress }: { progress: ProgressRef }) {
+ // CC0: Quaternius "Phone" — poly.pizza/m/k2kgBepoMU
+ const { scene } = useGLTF("/models/phone-quaternius.glb");
const rig = useRef(null);
const halo = useRef(null);
- const screenMat = useRef(null);
- const glow = 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]);
- useFrame((_, dt) => {
+ // materiais: corpo metálico; primitiva "Black" = tela com o site vivo
+ const materials = useMemo(() => {
+ const body = new THREE.MeshStandardMaterial({
+ color: "#232429",
+ metalness: 0.85,
+ roughness: 0.38,
+ });
+ const frame = new THREE.MeshStandardMaterial({
+ color: "#3a3b42",
+ metalness: 0.9,
+ roughness: 0.3,
+ });
+ const screen = new THREE.MeshStandardMaterial({
+ color: "#000000",
+ map: texture,
+ emissive: "#ffffff",
+ emissiveMap: texture,
+ emissiveIntensity: 1.15,
+ roughness: 0.4,
+ metalness: 0,
+ });
+ return { body, frame, screen };
+ }, [texture]);
+
+ useFrame((state, dt) => {
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
const p = smooth.current;
- const g = rig.current;
- if (!g) return;
-
- // tomada contínua: ~360° + um extra, como um turntable pré-renderizado
- g.rotation.y = -0.7 + p * Math.PI * 2.2;
- g.rotation.x = 0.1 + Math.sin(p * Math.PI) * 0.28;
- g.rotation.z = Math.sin(p * Math.PI * 2) * 0.06;
- g.position.x = 2.1;
- g.position.y = Math.sin(p * Math.PI * 2) * 0.22;
- // respiro de escala durante o reel (zoom de câmera sutil)
- g.scale.setScalar(0.62 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.1);
-
+ if (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;
}
- if (screenMat.current) {
- // tela "liga" cena a cena: 4 pulsos ao longo do reel
- screenMat.current.emissiveIntensity =
- 0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
- }
- if (glow.current) {
- glow.current.intensity = 2.2 + Math.sin(p * Math.PI * 4) * 1.4;
+ // 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 (
- {/* corpo do dispositivo */}
- {/* borda luminosa (bezel slash) */}
-
-
-
- {/* tela emissiva */}
- {/* halo orbital */}
-
-
-
-
-
);
}
+// 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;
- // leve steadicam: aproxima no meio da história
+ // steadicam sutil: aproxima no meio da história
camera.position.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8;
camera.position.y = -0.3 + p * 0.6;
camera.lookAt(0, 0, 0);
@@ -111,9 +207,12 @@ export default function StoryStage3D({ progress }: { progress: ProgressRef }) {
camera={{ position: [0, 0, 7.8], fov: 30 }}
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
>
-
-
-
+
+
+
+ }>
+
+
diff --git a/app/components/story/phoneScreenTexture.ts b/app/components/story/phoneScreenTexture.ts
new file mode 100644
index 0000000..ccbae13
--- /dev/null
+++ b/app/components/story/phoneScreenTexture.ts
@@ -0,0 +1,183 @@
+// Gera a textura "site vivo" da tela do celular: um mini-portfolio desenhado em
+// canvas 2D, com os prints reais dos projetos em produção rolando verticalmente
+// conforme o progresso do reel — como se o site estivesse aberto no aparelho.
+// Fotos dos projetos: capturadas ao vivo dos deployments (headless Chrome).
+// CC0 model: "Phone" by Quaternius (https://quaternius.com) via poly.pizza.
+
+type Shot = { img: HTMLImageElement; name: string; tag: string };
+
+const SHOTS: Array<{ src: string; name: string; tag: string }> = [
+ { src: "/assets/prints/print-nutriscan.png", name: "NutriScan", tag: "Open Food Facts · PWA" },
+ { src: "/assets/prints/print-scplus.png", name: "SCPLUS", tag: "Educação + IoT · Garuva-SC" },
+ { src: "/assets/prints/print-sev.png", name: "SEV Moda Íntima", tag: "E-commerce · Next.js" },
+ { src: "/assets/prints/print-asservicos.png", name: "AS Serviços", tag: "Institucional · SSR" },
+];
+
+export type ScreenPainter = {
+ canvas: HTMLCanvasElement;
+ /** desenha o frame para o progresso p∈[0,1] do reel; retorna true se mudou */
+ paint: (p: number, t: number) => boolean;
+ ready: () => boolean;
+};
+
+const W = 512;
+const H = 1024;
+const INK = "#0a0a0a";
+const PAPER = "#f4f2ed";
+const SLASH = "#ccff00";
+
+export function createPhoneScreen(): ScreenPainter {
+ const canvas = document.createElement("canvas");
+ canvas.width = W;
+ canvas.height = H;
+ const ctx = canvas.getContext("2d");
+ const images: Shot[] = [];
+ let lastKey = "";
+
+ for (const s of SHOTS) {
+ const img = new Image();
+ img.decoding = "async";
+ img.onload = () => images.push({ img, name: s.name, tag: s.tag });
+ img.src = s.src;
+ }
+
+ function roundRect(x: number, y: number, w: number, h: number, r: number) {
+ if (!ctx) return;
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ }
+
+ function wrap(text: string, x: number, y: number, maxW: number, lh: number) {
+ if (!ctx) return;
+ const words = text.split(" ");
+ let line = "";
+ let yy = y;
+ for (const word of words) {
+ const candidate = line ? `${line} ${word}` : word;
+ if (ctx.measureText(candidate).width > maxW) {
+ ctx.fillText(line, x, yy);
+ line = word;
+ yy += lh;
+ } else line = candidate;
+ }
+ if (line) ctx.fillText(line, x, yy);
+ }
+
+ function drawHero(offset: number, t: number) {
+ if (!ctx) return;
+ if (offset > H || offset < -H) return;
+ ctx.save();
+ ctx.translate(0, offset);
+ const pulse = 0.6 + 0.4 * Math.sin(t / 600);
+ ctx.fillStyle = SLASH;
+ ctx.globalAlpha = 0.14 + pulse * 0.06;
+ ctx.fillRect(0, 60, W, 2);
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = "rgba(244,242,237,.55)";
+ ctx.font = "600 20px ui-monospace, monospace";
+ ctx.fillText("PORTFÓLIO · AO VIVO", 36, 150);
+ ctx.fillStyle = PAPER;
+ ctx.font = "800 72px system-ui, sans-serif";
+ ctx.fillText("Software", 36, 260);
+ ctx.fillText("que", 36, 340);
+ ctx.fillStyle = SLASH;
+ ctx.fillText("opera.", 36, 420);
+ ctx.fillStyle = "rgba(244,242,237,.66)";
+ ctx.font = "400 28px system-ui, sans-serif";
+ wrap("Engenharia de sistemas que precisam funcionar de verdade.", 36, 500, W - 72, 40);
+ ctx.fillStyle = SLASH;
+ roundRect(36, 700, 250, 72, 36);
+ ctx.fill();
+ ctx.fillStyle = INK;
+ ctx.font = "700 26px ui-monospace, monospace";
+ ctx.fillText("VER PROJETOS ↓", 60, 744);
+ ctx.restore();
+ }
+
+ function drawProject(shot: Shot, offset: number, i: number, t: number) {
+ if (!ctx) return;
+ ctx.save();
+ ctx.translate(0, offset);
+ ctx.fillStyle = "rgba(244,242,237,.5)";
+ ctx.font = "600 18px ui-monospace, monospace";
+ ctx.fillText(`EM PRODUÇÃO · 0${i + 1}`, 36, 110);
+ ctx.fillStyle = PAPER;
+ ctx.font = "800 46px system-ui, sans-serif";
+ ctx.fillText(shot.name, 36, 168);
+ ctx.fillStyle = "rgba(204,255,0,.85)";
+ ctx.font = "400 22px system-ui, sans-serif";
+ ctx.fillText(shot.tag, 36, 204);
+
+ // o print real, recortado, com leve auto-scroll dentro do card
+ const cardX = 36;
+ const cardY = 236;
+ const cardW = W - 72;
+ const cardH = 620;
+ ctx.save();
+ roundRect(cardX, cardY, cardW, cardH, 28);
+ ctx.clip();
+ ctx.fillStyle = "#111";
+ ctx.fillRect(cardX, cardY, cardW, cardH);
+ const scale = cardW / shot.img.width;
+ const drift =
+ (Math.sin(t / 1400 + i * 2.1) * 0.5 + 0.5) *
+ Math.max(0, shot.img.height * scale - cardH);
+ ctx.drawImage(shot.img, cardX, cardY - drift, cardW, shot.img.height * scale);
+ const grd = ctx.createLinearGradient(cardX, cardY, cardX, cardY + cardH);
+ grd.addColorStop(0, "rgba(255,255,255,.08)");
+ grd.addColorStop(0.12, "rgba(255,255,255,0)");
+ ctx.fillStyle = grd;
+ ctx.fillRect(cardX, cardY, cardW, cardH);
+ ctx.restore();
+ ctx.strokeStyle = "rgba(255,255,255,.12)";
+ ctx.lineWidth = 2;
+ roundRect(cardX, cardY, cardW, cardH, 28);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ function paint(p: number, t: number) {
+ if (!ctx || images.length === 0) return false;
+ const key = `${p.toFixed(4)}|${Math.floor(t / 250)}|${images.length}`;
+ if (key === lastKey) return false;
+ lastKey = key;
+
+ ctx.fillStyle = INK;
+ ctx.fillRect(0, 0, W, H);
+
+ // status bar do "aparelho"
+ ctx.fillStyle = PAPER;
+ ctx.font = "600 22px ui-monospace, monospace";
+ ctx.fillText("21:37", 28, 38);
+ ctx.fillStyle = SLASH;
+ ctx.fillRect(W - 96, 22, 44, 14);
+ ctx.fillStyle = PAPER;
+ ctx.font = "600 16px ui-monospace, monospace";
+ ctx.fillText("E:/", W - 40, 36);
+
+ // o mini-site rolando: hero + página por projeto
+ const pageH = H - 60;
+ const y = p * images.length * pageH;
+ drawHero(-y, t);
+ images.forEach((shot, i) => {
+ const top = (i + 1) * pageH - y;
+ if (top > H || top < -pageH) return;
+ drawProject(shot, top, i, t);
+ });
+ return true;
+ }
+
+ return {
+ canvas,
+ paint,
+ ready: () => images.length > 0,
+ };
+}
+
+export const PHONE_SCREEN_W = W;
+export const PHONE_SCREEN_H = H;
diff --git a/public/assets/prints/print-asservicos.png b/public/assets/prints/print-asservicos.png
new file mode 100644
index 0000000..829cb91
Binary files /dev/null and b/public/assets/prints/print-asservicos.png differ
diff --git a/public/assets/prints/print-nutriscan.png b/public/assets/prints/print-nutriscan.png
new file mode 100644
index 0000000..f359dcc
Binary files /dev/null and b/public/assets/prints/print-nutriscan.png differ
diff --git a/public/assets/prints/print-scplus.png b/public/assets/prints/print-scplus.png
new file mode 100644
index 0000000..37654da
Binary files /dev/null and b/public/assets/prints/print-scplus.png differ
diff --git a/public/assets/prints/print-sev.png b/public/assets/prints/print-sev.png
new file mode 100644
index 0000000..0d9350f
Binary files /dev/null and b/public/assets/prints/print-sev.png differ
diff --git a/public/models/phone-quaternius.glb b/public/models/phone-quaternius.glb
new file mode 100644
index 0000000..356c518
Binary files /dev/null and b/public/models/phone-quaternius.glb differ