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