feat: control room hero, SEO/AEO optimization and design system fixes

Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
This commit is contained in:
Evandro Fonseca Junior committed 2026-08-27 10:55:21 -03:00
1 parent 0920370f30
commit b791b7de9e
94 files changed
+2621 -2889

No files matched your search

+14 -6
View File
@@ -1,21 +1,29 @@
"use client";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import PageIntro from "./PageIntro";
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
ssr: false,
});
export default function CinematicOverlays() {
const pathname = usePathname();
const isHome = pathname === "/";
return (
<>
<PageIntro />
<CustomCursor />
<FilmGrain />
<ScrollProgress />
<HomeButton />
{isHome ? (
<>
<CustomCursor />
<FilmGrain />
<ScrollProgress />
</>
) : null}
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
interface CinematicSectionProps {
children: ReactNode;
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect } from "react";
import { useEffect, useRef } from "react";
interface CinematicTextProps {
text: string;
@@ -0,0 +1,202 @@
"use client";
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const MOVEMENT_DAMPING = 1400;
const JOINVILLE_LAT = -26.3044;
const JOINVILLE_LNG = -48.8456;
// cobe espera radianos: theta = latitude da banda de frente (Trópico de Capricórnio), phi = longitude de frente (Joinville)
const TROPIC_OF_CAPRICORN_RAD = (-23.4364 * Math.PI) / 180;
const JOINVILLE_LNG_RAD = (JOINVILLE_LNG * Math.PI) / 180;
const CONTROL_ROOM_GLOBE_CONFIG: COBEOptions = {
width: 800,
height: 800,
onRender: () => {},
devicePixelRatio: 2,
phi: JOINVILLE_LNG_RAD,
theta: TROPIC_OF_CAPRICORN_RAD,
dark: 0,
diffuse: 1.2,
mapSamples: 16000,
mapBrightness: 6,
baseColor: [0.32, 0.32, 0.32],
markerColor: [204 / 255, 255 / 255, 0 / 255],
glowColor: [204 / 255, 255 / 255, 0 / 255],
markers: [
{ location: [JOINVILLE_LAT, JOINVILLE_LNG], size: 0.12 },
{ location: [-25.4284, -49.2733], size: 0.06 },
{ location: [-26.0167, -48.5], size: 0.05 },
{ location: [-25.9333, -48.7833], size: 0.04 },
],
} as const satisfies COBEOptions;
export function ControlRoomGlobe({
className,
config = CONTROL_ROOM_GLOBE_CONFIG,
autoRotate = true,
rotationSpeed = 0.003,
}: {
className?: string;
config?: COBEOptions;
autoRotate?: boolean;
rotationSpeed?: number;
}) {
const [width, setWidth] = useState(0);
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
const thetaPhaseRef = useRef(0);
const thetaDragRef = useRef(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerYOrigin = useRef<number | null>(null);
const isVisibleRef = useRef(true);
const r = useMotionValue(0);
const rs = useSpring(r, {
mass: 1,
damping: 30,
stiffness: 100,
});
const updatePointerInteraction = (value: number | null) => {
pointerInteracting.current = value;
if (canvasRef.current) {
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
}
};
const updateMovement = (clientX: number, clientY?: number) => {
if (pointerInteracting.current !== null) {
const delta = clientX - pointerInteracting.current;
r.set(r.get() + delta / MOVEMENT_DAMPING);
// Eixo vertical: arrasto inclina o globo (limitado p/ não virar de cabeça pra baixo)
if (clientY !== undefined && pointerYOrigin.current !== null) {
const deltaY = clientY - pointerYOrigin.current;
thetaDragRef.current = Math.max(
-0.9,
Math.min(0.9, thetaDragRef.current + deltaY / MOVEMENT_DAMPING),
);
pointerYOrigin.current = clientY;
}
}
};
useEffect(() => {
const onResize = () => {
if (canvasRef.current) {
setWidth(canvasRef.current.offsetWidth);
}
};
window.addEventListener("resize", onResize);
onResize();
const observer = new IntersectionObserver(
([entry]) => {
isVisibleRef.current = entry.isIntersecting;
},
{ rootMargin: "100px" },
);
if (canvasRef.current) {
observer.observe(canvasRef.current);
}
const handleVisibilityChange = () => {
isVisibleRef.current = !document.hidden;
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const canvas = canvasRef.current;
if (!canvas) {
return () => {
window.removeEventListener("resize", onResize);
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
);
observer.disconnect();
};
}
const globe = createGlobe(canvas, {
...config,
width: width * 2,
height: width * 2,
onRender: (state) => {
if (!isVisibleRef.current || document.hidden) return;
if (autoRotate && !pointerInteracting.current) {
phiRef.current += rotationSpeed;
thetaPhaseRef.current += rotationSpeed * 0.9;
}
state.phi = phiRef.current + rs.get();
// Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical
state.theta =
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
Math.sin(thetaPhaseRef.current) * 0.35 +
thetaDragRef.current;
state.width = width * 2;
state.height = width * 2;
},
});
setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = "1";
}
}, 0);
return () => {
globe.destroy();
window.removeEventListener("resize", onResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
observer.disconnect();
};
}, [rs, config, autoRotate, rotationSpeed, width]);
return (
<div
className={cn(
"relative mx-auto aspect-[1/1] w-full max-w-[520px]",
className,
)}
aria-hidden="true"
>
<canvas
className={cn(
"size-full opacity-0 transition-opacity duration-700 [contain:layout_paint_size]",
)}
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current = e.clientX;
pointerYOrigin.current = e.clientY;
updatePointerInteraction(e.clientX);
}}
onPointerUp={() => {
updatePointerInteraction(null);
pointerYOrigin.current = null;
}}
onPointerOut={() => updatePointerInteraction(null)}
onPointerLeave={() => {
updatePointerInteraction(null);
pointerYOrigin.current = null;
}}
onMouseMove={(e) => updateMovement(e.clientX, e.clientY)}
onTouchMove={(e) =>
e.touches[0] &&
updateMovement(e.touches[0].clientX, e.touches[0].clientY)
}
/>
<span className="sr-only">
Globo interativo centrado em Joinville, Santa Catarina, com marcadores
indicando presença operacional na região Norte de SC e Curitiba.
</span>
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { motion, useMotionValue } from "framer-motion";
import { motion, useMotionValue } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
export default function CustomCursor() {
const [isHovering, setIsHovering] = useState(false);
+7 -2
View File
@@ -9,6 +9,12 @@ export default function FilmGrain() {
const canvas = canvasRef.current;
if (!canvas) return;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
if (reducedMotion || coarsePointer) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
@@ -57,9 +63,8 @@ export default function FilmGrain() {
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
style={{ imageRendering: "pixelated" }}
aria-hidden="true"
/>
);
}
+28 -6
View File
@@ -1,11 +1,21 @@
"use client";
import { useRef, useMemo } from "react";
import {
Float,
MeshDistortMaterial,
MeshTransmissionMaterial,
} from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Float, MeshDistortMaterial, MeshTransmissionMaterial } from "@react-three/drei";
import { useMemo, useRef } from "react";
import * as THREE from "three";
function GlassSphere({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
function GlassSphere({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
@@ -32,7 +42,13 @@ function GlassSphere({ position, scale = 1 }: { position: [number, number, numbe
);
}
function WireframeRing({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
function WireframeRing({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
@@ -80,7 +96,9 @@ function DataStream({ count = 50 }: { count?: number }) {
const t = state.clock.elapsedTime;
pointsRef.current.rotation.y = t * 0.05;
const posAttr = pointsRef.current.geometry.getAttribute("position") as THREE.BufferAttribute;
const posAttr = pointsRef.current.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
for (let i = 0; i < count; i++) {
@@ -127,7 +145,11 @@ export default function FloatingElements({
<Canvas
camera={{ position: [0, 0, 6], fov: 50 }}
dpr={[1, 1.5]}
gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }}
gl={{
antialias: false,
alpha: true,
powerPreference: "high-performance",
}}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.2} />
+7 -243
View File
@@ -1,8 +1,7 @@
"use client";
import { Float, MeshDistortMaterial } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
type HeroSceneVariant =
@@ -13,236 +12,6 @@ type HeroSceneVariant =
| "diagnostic"
| "portrait";
function ParticleField({ count = 2000 }: { count?: number }) {
const mesh = useRef<THREE.Points>(null);
const mouseRef = useRef({ x: 0, y: 0 });
const [positions, velocities] = useMemo(() => {
const pos = new Float32Array(count * 3);
const vel = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
pos[i3] = (Math.random() - 0.5) * 20;
pos[i3 + 1] = (Math.random() - 0.5) * 20;
pos[i3 + 2] = (Math.random() - 0.5) * 15;
vel[i3] = (Math.random() - 0.5) * 0.002;
vel[i3 + 1] = (Math.random() - 0.5) * 0.002;
vel[i3 + 2] = (Math.random() - 0.5) * 0.001;
}
return [pos, vel];
}, [count]);
const sizes = useMemo(() => {
const s = new Float32Array(count);
for (let i = 0; i < count; i++) {
s[i] = Math.random() * 2 + 0.5;
}
return s;
}, [count]);
const handlePointerMove = useCallback(
(e: { clientX: number; clientY: number }) => {
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
},
[],
);
useFrame((state) => {
if (!mesh.current) return;
const geo = mesh.current.geometry;
const posAttr = geo.getAttribute("position") as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
const time = state.clock.elapsedTime;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
arr[i3] += velocities[i3] + Math.sin(time * 0.3 + i) * 0.001;
arr[i3 + 1] += velocities[i3 + 1] + Math.cos(time * 0.2 + i) * 0.001;
arr[i3 + 2] += velocities[i3 + 2];
// Mouse influence
const dx = mouseRef.current.x * 5 - arr[i3];
const dy = mouseRef.current.y * 5 - arr[i3 + 1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 3) {
const force = (3 - dist) * 0.0003;
arr[i3] += dx * force;
arr[i3 + 1] += dy * force;
}
// Wrap around
if (arr[i3] > 10) arr[i3] = -10;
if (arr[i3] < -10) arr[i3] = 10;
if (arr[i3 + 1] > 10) arr[i3 + 1] = -10;
if (arr[i3 + 1] < -10) arr[i3 + 1] = 10;
if (arr[i3 + 2] > 7.5) arr[i3 + 2] = -7.5;
if (arr[i3 + 2] < -7.5) arr[i3 + 2] = 7.5;
}
posAttr.needsUpdate = true;
// Slow rotation
mesh.current.rotation.y = time * 0.02;
mesh.current.rotation.x = Math.sin(time * 0.01) * 0.1;
});
useEffect(() => {
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [handlePointerMove]);
return (
<points ref={mesh}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
<bufferAttribute
attach="attributes-size"
args={[sizes, 1]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color="#ccff00"
size={0.03}
transparent
opacity={0.6}
sizeAttenuation
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
function FloatingGeometry({ variant }: { variant: HeroSceneVariant }) {
const meshRef = useRef<THREE.Mesh>(null);
const mesh2Ref = useRef<THREE.Mesh>(null);
const mesh3Ref = useRef<THREE.Mesh>(null);
const layout = {
command: {
a: [4, 1.5, -3] as [number, number, number],
b: [-5, -2, -4] as [number, number, number],
c: [2, -3, -5] as [number, number, number],
scale: [1.2, 0.8, 1.5] as [number, number, number],
},
launch: {
a: [3.8, 2.1, -3] as [number, number, number],
b: [0, -1.4, -4] as [number, number, number],
c: [-3.8, -2.6, -5] as [number, number, number],
scale: [0.9, 0.9, 1.8] as [number, number, number],
},
rescue: {
a: [-3.6, 1.2, -3] as [number, number, number],
b: [3.6, 1.2, -4] as [number, number, number],
c: [0, -2.8, -5] as [number, number, number],
scale: [1, 1, 1.4] as [number, number, number],
},
signal: {
a: [-4.2, -1, -3] as [number, number, number],
b: [0.2, 1.6, -4] as [number, number, number],
c: [4.2, -0.6, -5] as [number, number, number],
scale: [0.7, 0.7, 1.2] as [number, number, number],
},
diagnostic: {
a: [4.6, 0.6, -3] as [number, number, number],
b: [-3.5, 1.8, -4] as [number, number, number],
c: [-1.2, -2.8, -5] as [number, number, number],
scale: [1.4, 0.7, 1.1] as [number, number, number],
},
portrait: {
a: [3.2, 0.4, -3] as [number, number, number],
b: [4.8, 2.1, -4] as [number, number, number],
c: [-3.6, -2.4, -5] as [number, number, number],
scale: [1.8, 0.5, 1.1] as [number, number, number],
},
}[variant];
useFrame((state) => {
const t = state.clock.elapsedTime;
if (meshRef.current) {
meshRef.current.rotation.x = t * 0.15;
meshRef.current.rotation.y = t * 0.1;
meshRef.current.position.y = layout.a[1] + Math.sin(t * 0.4) * 0.3;
}
if (mesh2Ref.current) {
mesh2Ref.current.rotation.x = t * 0.1;
mesh2Ref.current.rotation.z = t * 0.12;
mesh2Ref.current.position.y = layout.b[1] + Math.cos(t * 0.3) * 0.5;
}
if (mesh3Ref.current) {
mesh3Ref.current.rotation.y = t * 0.08;
mesh3Ref.current.rotation.z = t * 0.06;
}
});
return (
<>
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
<mesh ref={meshRef} position={layout.a} scale={layout.scale[0]}>
{variant === "diagnostic" ? (
<boxGeometry args={[1.4, 1.8, 0.08, 3, 3, 1]} />
) : variant === "portrait" ? (
<capsuleGeometry args={[0.55, 1.25, 6, 12]} />
) : (
<icosahedronGeometry args={[1, 1]} />
)}
<MeshDistortMaterial
color="#ccff00"
wireframe
transparent
opacity={variant === "portrait" ? 0.1 : 0.15}
distort={0.3}
speed={2}
/>
</mesh>
</Float>
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={mesh2Ref} position={layout.b} scale={layout.scale[1]}>
{variant === "launch" ? (
<coneGeometry args={[0.8, 1.6, 4]} />
) : variant === "signal" ? (
<sphereGeometry args={[0.55, 16, 16]} />
) : (
<octahedronGeometry args={[1, 0]} />
)}
<MeshDistortMaterial
color="#ffffff"
wireframe
transparent
opacity={0.08}
distort={0.2}
speed={1.5}
/>
</mesh>
</Float>
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
<mesh ref={mesh3Ref} position={layout.c} scale={layout.scale[2]}>
{variant === "rescue" ? (
<boxGeometry args={[1.4, 1.4, 1.4, 2, 2, 2]} />
) : variant === "signal" ? (
<torusKnotGeometry args={[0.7, 0.16, 80, 8]} />
) : (
<torusGeometry args={[1, 0.3, 16, 32]} />
)}
<meshStandardMaterial
color="#ccff00"
wireframe
transparent
opacity={0.06}
/>
</mesh>
</Float>
</>
);
}
function GridFloor() {
const meshRef = useRef<THREE.Mesh>(null);
@@ -334,11 +103,8 @@ function CameraRig() {
return null;
}
export default function HeroScene({
variant = "command",
}: {
variant?: HeroSceneVariant;
}) {
export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
void variant;
return (
<div
className="absolute inset-0 z-0"
@@ -362,18 +128,16 @@ export default function HeroScene({
display: "block",
}}
>
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
<ambientLight intensity={0.3} />
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
<ambientLight intensity={0.25} />
<directionalLight
position={[5, 5, 5]}
intensity={0.5}
intensity={0.4}
color="#ccff00"
/>
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
<CameraRig />
<ParticleField count={1500} />
<FloatingGeometry variant={variant} />
<GridFloor />
</Canvas>
</div>
+3 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import MagneticButton from "./MagneticButton";
export default function HomeButton() {
@@ -29,7 +29,8 @@ export default function HomeButton() {
<span
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
boxShadow: "inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
boxShadow:
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
}}
/>
@@ -63,4 +64,3 @@ export default function HomeButton() {
</AnimatePresence>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
interface HorizontalScrollProps {
children: ReactNode;
@@ -70,13 +70,17 @@ export default function HorizontalScroll({
end: "left 30%",
scrub: true,
},
}
},
);
}
cleanup = () => {
ScrollTrigger.getAll().forEach((st) => {
if (st.trigger === container || (st.vars as { containerAnimation?: unknown }).containerAnimation === tween) {
if (
st.trigger === container ||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
tween
) {
st.kill();
}
});
@@ -0,0 +1,41 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import MagneticButton from "./MagneticButton";
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
animate: _animate,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion };
});
describe("MagneticButton semantics", () => {
it("renders a list item directly when used inside a list", () => {
const { container } = render(
<ul>
<MagneticButton as="li" className="ds-tag">
TypeScript
</MagneticButton>
</ul>,
);
expect(container.querySelector("ul > li.ds-tag")).toBeInTheDocument();
expect(container.querySelector("ul > div > li")).not.toBeInTheDocument();
});
});
+51 -27
View File
@@ -1,7 +1,7 @@
"use client";
import { useRef, useState, type ReactNode } from "react";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { type MouseEvent, type ReactNode, useState } from "react";
interface MagneticButtonProps {
children: ReactNode;
@@ -34,14 +34,10 @@ export default function MagneticButton({
wrapperClassName = "",
"aria-label": ariaLabel,
}: MagneticButtonProps) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouse = (e: React.MouseEvent) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const handleMouse = (e: MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
@@ -55,31 +51,59 @@ export default function MagneticButton({
setPosition({ x: 0, y: 0 });
};
const Tag = as === "a" ? motion.a : as === "button" ? motion.button : as === "div" ? motion.div : as === "span" ? motion.span : motion.li;
const Tag =
as === "a"
? motion.a
: as === "button"
? motion.button
: as === "div"
? motion.div
: as === "span"
? motion.span
: motion.li;
const linkProps = as === "a" ? { href, target, rel, download } : {};
const buttonProps = as === "button" ? { type } : {};
return (
<div
ref={ref}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
className={`inline-block ${wrapperClassName}`.trim()}
>
const motionProps = {
animate: {
x: position.x,
y: position.y,
},
transition: {
type: "spring" as const,
stiffness: 350,
damping: 15,
mass: 0.5,
},
};
if (as === "li") {
return (
<Tag
className={className}
className={`${className} ${wrapperClassName}`.trim()}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
animate={{
x: position.x,
y: position.y,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 15,
mass: 0.5,
}}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
>
{children}
</Tag>
);
}
return (
<div className={`inline-block ${wrapperClassName}`.trim()}>
<Tag
className={className}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
+5 -5
View File
@@ -8,7 +8,7 @@ jest.mock("react-dom", () => ({
createPortal: (children: ReactNode) => children,
}));
jest.mock("framer-motion", () => {
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
@@ -31,9 +31,7 @@ describe("PageIntro", () => {
it("renders the compact signal loading state without a fake percentage", () => {
render(<PageIntro />);
expect(
screen.getByRole("heading", { name: /EVANDRO.*DEV/ }),
).toBeInTheDocument();
expect(screen.getByText(/EVANDRO.*DEV/)).toBeInTheDocument();
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
expect(screen.queryByText("000")).not.toBeInTheDocument();
@@ -47,7 +45,9 @@ describe("PageIntro", () => {
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
act(() => jest.runAllTimers());
expect(screen.queryByText("Preparando o portfólio")).not.toBeInTheDocument();
expect(
screen.queryByText("Preparando o portfólio"),
).not.toBeInTheDocument();
jest.useRealTimers();
});
});
+7 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
@@ -42,7 +42,7 @@ export default function PageIntro() {
aria-label="Preparando o portfólio"
>
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="pointer-events-none absolute inset-x-0 top-[42%] h-px bg-[var(--slash)] opacity-70" />
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
<div className="relative m-auto w-[min(88vw,680px)]">
<motion.p
@@ -53,8 +53,9 @@ export default function PageIntro() {
>
SINAL DE ENTRADA
</motion.p>
<motion.h1
className="mt-sp-4 font-display text-[clamp(3rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.08em]"
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
<motion.p
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -64,7 +65,7 @@ export default function PageIntro() {
}}
>
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
</motion.h1>
</motion.p>
<motion.div
className="mt-sp-8 flex items-center gap-sp-4"
initial={reduceMotion ? false : { opacity: 0 }}
@@ -94,7 +95,7 @@ export default function PageIntro() {
</motion.div>
</div>
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[9px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.45)] lg:bottom-10 lg:left-12 lg:right-12">
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
<span>Engenharia de software</span>
<span>Garuva · Joinville · Curitiba</span>
</div>
@@ -0,0 +1,397 @@
"use client";
import { useEffect, useRef } from "react";
const DEFAULT_TEXT =
"Engenharia de software para sistemas que precisam operar de verdade.";
const MAX_DPR = 1.5;
const ASSEMBLE_MS = 2000;
const REPEL_RADIUS_PX = 80;
const MIN_PARTICLES = 1200;
const MAX_PARTICLES = 2500;
const OFFSCREEN_POINTER = -100000;
type Particle = {
sx: number;
sy: number;
tx: number;
ty: number;
ox: number;
oy: number;
delay: number;
color: string;
size: number;
phase: number;
speed: number;
amp: number;
};
type TrackingCapableContext = CanvasRenderingContext2D & {
letterSpacing?: string;
};
type Rgb = [number, number, number];
const FALLBACK_SLASH: Rgb = [204, 255, 0];
const FALLBACK_PAPER: Rgb = [244, 242, 237];
function hexToRgb(hex: string): Rgb | null {
const value = hex.trim().replace("#", "");
const full =
value.length === 3
? value
.split("")
.map((char) => char + char)
.join("")
: value;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return [
Number.parseInt(full.slice(0, 2), 16),
Number.parseInt(full.slice(2, 4), 16),
Number.parseInt(full.slice(4, 6), 16),
];
}
function easeOutQuint(progress: number) {
return 1 - (1 - progress) ** 5;
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
) {
const lines: string[] = [];
let current = "";
for (const word of text.split(" ")) {
const candidate = current ? `${current} ${word}` : word;
if (!current || ctx.measureText(candidate).width <= maxWidth) {
current = candidate;
} else {
lines.push(current);
current = word;
}
}
if (current) lines.push(current);
return lines;
}
export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let rafId = 0;
let running = false;
let cancelled = false;
let resampleTimer = 0;
let particles: Particle[] = [];
let hasAssembled = false;
let elapsed = 0;
let lastTime = 0;
let dpr = 1;
let intersecting = true;
const pointer = { x: OFFSCREEN_POINTER, y: OFFSCREEN_POINTER };
let rect = container.getBoundingClientRect();
const rootStyles = getComputedStyle(document.documentElement);
const slashRgb =
hexToRgb(rootStyles.getPropertyValue("--slash")) ?? FALLBACK_SLASH;
const paperRgb =
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
// next/font gera nome de família com hash: resolver pela classe font-display real
const probe = document.createElement("span");
probe.className = "font-display";
probe.style.position = "absolute";
probe.style.opacity = "0";
document.body.appendChild(probe);
const fontFamily = getComputedStyle(probe).fontFamily || "sans-serif";
document.body.removeChild(probe);
const fontsReady =
typeof document.fonts === "undefined"
? Promise.resolve()
: document.fonts.ready;
const sample = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (width < 60 || height < 60) return;
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
rect = container.getBoundingClientRect();
const offscreen = document.createElement("canvas");
offscreen.width = canvas.width;
offscreen.height = canvas.height;
const offCtx = offscreen.getContext("2d");
if (!offCtx) return;
// Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro
offCtx.scale(dpr, dpr);
const maxWidth = width * 0.92;
let fontSize = Math.min(height * 0.22, width / 7);
let lines: string[] = [];
while (fontSize >= 12) {
offCtx.font = `800 ${fontSize}px ${fontFamily}`;
const trackingCtx = offCtx as TrackingCapableContext;
if (trackingCtx.letterSpacing !== undefined) {
trackingCtx.letterSpacing = "-0.04em";
}
lines = wrapLines(offCtx, text, maxWidth);
if (lines.length * fontSize * 0.98 <= height * 0.8) break;
fontSize *= 0.94;
}
fontSize = Math.max(fontSize, 12);
const lineHeight = fontSize * 0.98;
const startY = (height - lines.length * lineHeight) / 2 + fontSize * 0.74;
offCtx.fillStyle = "#ffffff";
offCtx.textBaseline = "alphabetic";
lines.forEach((line, index) => {
const lineWidth = offCtx.measureText(line).width;
offCtx.fillText(
line,
(width - lineWidth) / 2,
startY + index * lineHeight,
);
});
const pixels = offCtx.getImageData(
0,
0,
offscreen.width,
offscreen.height,
).data;
const targets: Array<[number, number]> = [];
const stride = 3;
for (let py = 0; py < offscreen.height; py += stride) {
for (let px = 0; px < offscreen.width; px += stride) {
if (pixels[(py * offscreen.width + px) * 4 + 3] > 128) {
targets.push([px, py]);
}
}
}
if (targets.length === 0) return;
const desired = Math.round(
Math.min(
MAX_PARTICLES,
Math.max(MIN_PARTICLES, (width * height) / 180),
),
);
particles = Array.from({ length: desired }, () => {
const target = targets[Math.floor(Math.random() * targets.length)];
const useSlash = Math.random() < 0.72;
const rgb = useSlash ? slashRgb : paperRgb;
const alpha = useSlash
? 0.28 + Math.random() * 0.22
: 0.25 + Math.random() * 0.15;
return {
tx: target[0],
ty: target[1],
sx: hasAssembled
? target[0]
: (Math.random() * 1.3 - 0.15) * canvas.width,
sy: hasAssembled
? target[1]
: (Math.random() * 1.3 - 0.15) * canvas.height,
ox: 0,
oy: 0,
delay: Math.random() * 0.35,
color: `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha.toFixed(2)})`,
size: (Math.random() < 0.85 ? 1 : 2) * dpr,
phase: Math.random() * Math.PI * 2,
speed: 0.0005 + Math.random() * 0.0007,
amp: (0.6 + Math.random()) * dpr,
};
});
};
const render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const pointerX = (pointer.x - rect.left) * dpr;
const pointerY = (pointer.y - rect.top) * dpr;
const radius = REPEL_RADIUS_PX * dpr;
const radiusSq = radius * radius;
let allSettled = true;
for (const particle of particles) {
const progress = Math.min(
Math.max(
(elapsed - particle.delay * ASSEMBLE_MS) / (ASSEMBLE_MS * 0.75),
0,
),
1,
);
if (progress < 1) allSettled = false;
const eased = easeOutQuint(progress);
const baseX = particle.sx + (particle.tx - particle.sx) * eased;
const baseY = particle.sy + (particle.ty - particle.sy) * eased;
const driftX =
Math.sin(elapsed * particle.speed + particle.phase) *
particle.amp *
progress;
const driftY =
Math.cos(elapsed * particle.speed * 0.9 + particle.phase) *
particle.amp *
progress;
const dx = baseX + driftX - pointerX;
const dy = baseY + driftY - pointerY;
const distSq = dx * dx + dy * dy;
if (distSq < radiusSq) {
const dist = Math.sqrt(distSq) || 1;
const force = (1 - dist / radius) * 10 * dpr;
particle.ox += (dx / dist) * force;
particle.oy += (dy / dist) * force;
}
particle.ox *= 0.86;
particle.oy *= 0.86;
ctx.fillStyle = particle.color;
ctx.fillRect(
baseX + driftX + particle.ox,
baseY + driftY + particle.oy,
particle.size,
particle.size,
);
}
if (allSettled && particles.length > 0) hasAssembled = true;
};
const drawStaticFrame = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const particle of particles) {
ctx.fillStyle = particle.color;
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
}
};
const tick = (now: number) => {
elapsed += Math.min(now - lastTime, 50);
lastTime = now;
render();
};
const syncLoop = () => {
const shouldRun = !reducedMotion && intersecting && !document.hidden;
if (shouldRun && !running) {
running = true;
lastTime = performance.now();
rafId = window.requestAnimationFrame(tick);
} else if (!shouldRun && running) {
running = false;
window.cancelAnimationFrame(rafId);
}
};
const scheduleResample = () => {
window.clearTimeout(resampleTimer);
resampleTimer = window.setTimeout(() => {
sample();
if (reducedMotion) drawStaticFrame();
}, 150);
};
const handlePointerMove = (event: PointerEvent) => {
pointer.x = event.clientX;
pointer.y = event.clientY;
};
const handlePointerLeave = () => {
pointer.x = OFFSCREEN_POINTER;
pointer.y = OFFSCREEN_POINTER;
};
const handleScroll = () => {
rect = container.getBoundingClientRect();
};
const handleVisibilityChange = () => {
syncLoop();
};
const resizeObserver = new ResizeObserver(scheduleResample);
resizeObserver.observe(container);
if (reducedMotion) {
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
fontsReady.then(() => {
if (cancelled) return;
hasAssembled = true;
sample();
drawStaticFrame();
});
return () => {
cancelled = true;
window.clearTimeout(resampleTimer);
resizeObserver.disconnect();
};
}
const intersectionObserver = new IntersectionObserver((entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
syncLoop();
});
intersectionObserver.observe(container);
window.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
document.documentElement.addEventListener(
"pointerleave",
handlePointerLeave,
);
window.addEventListener("scroll", handleScroll, { passive: true });
document.addEventListener("visibilitychange", handleVisibilityChange);
fontsReady.then(() => {
if (cancelled) return;
sample();
syncLoop();
});
return () => {
cancelled = true;
window.cancelAnimationFrame(rafId);
window.clearTimeout(resampleTimer);
resizeObserver.disconnect();
intersectionObserver.disconnect();
window.removeEventListener("pointermove", handlePointerMove);
document.documentElement.removeEventListener(
"pointerleave",
handlePointerLeave,
);
window.removeEventListener("scroll", handleScroll);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [text]);
return (
<div
ref={containerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
);
}
+6 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
import { motion, useScroll, useSpring, useTransform } from "motion/react";
export default function ScrollProgress() {
const { scrollYProgress } = useScroll();
@@ -10,7 +10,11 @@ export default function ScrollProgress() {
restDelta: 0.001,
});
const opacity = useTransform(scrollYProgress, [0, 0.02, 0.98, 1], [0, 1, 1, 0]);
const opacity = useTransform(
scrollYProgress,
[0, 0.02, 0.98, 1],
[0, 1, 1, 0],
);
return (
<>
@@ -1,14 +1,14 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useTransform,
useSpring,
useScroll,
} from "framer-motion";
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
interface ScrollVelocityTextProps {
text: string;
@@ -89,7 +89,9 @@ export default function ScrollVelocityText({
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
>
{text}
<span className="mx-8 inline-block text-[var(--slash)]">&#x2022;</span>
<span className="mx-8 inline-block text-[var(--slash)]">
&#x2022;
</span>
</span>
))}
</motion.div>
+4 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect } from "react";
import { useEffect, useRef } from "react";
interface SectionDividerProps {
variant?: "gradient" | "pulse" | "waveform";
@@ -84,7 +84,9 @@ export default function SectionDivider({
if (variant === "pulse") {
return (
<div className={`flex items-center justify-center gap-2 py-8 ${className}`}>
<div
className={`flex items-center justify-center gap-2 py-8 ${className}`}
>
{[...Array(5)].map((_, i) => (
<div
key={i}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface TelemetryMetric {
label: string;
value: number;
unit: string;
precision?: number;
min?: number;
max?: number;
}
const METRICS_CONFIG: TelemetryMetric[] = [
{
label: "UPTIME",
value: 99.97,
unit: "%",
precision: 2,
min: 99.9,
max: 100,
},
{
label: "INTEGRAÇÕES",
value: 12,
unit: " ativas",
precision: 0,
min: 8,
max: 16,
},
{
label: "BUILDS",
value: 247,
unit: " /dia",
precision: 0,
min: 200,
max: 300,
},
{ label: "LATÊNCIA", value: 12, unit: " ms", precision: 0, min: 8, max: 28 },
];
const UPDATE_INTERVAL = 3000;
const springConfigs = [
{ stiffness: 300, damping: 30, mass: 0.8 },
{ stiffness: 200, damping: 25, mass: 1 },
{ stiffness: 150, damping: 20, mass: 1.2 },
{ stiffness: 150, damping: 20, mass: 1.2 },
];
export function TelemetryStrip({
className,
reducedMotion = false,
}: {
className?: string;
reducedMotion?: boolean;
}) {
const [_metrics, setMetrics] = useState<TelemetryMetric[]>(METRICS_CONFIG);
const pointerX = useMotionValue(0);
const pointerY = useMotionValue(0);
const isHovering = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const metricValue0 = useSpring(
useMotionValue(METRICS_CONFIG[0].value),
springConfigs[0],
);
const metricValue1 = useSpring(
useMotionValue(METRICS_CONFIG[1].value),
springConfigs[1],
);
const metricValue2 = useSpring(
useMotionValue(METRICS_CONFIG[2].value),
springConfigs[2],
);
const metricValue3 = useSpring(
useMotionValue(METRICS_CONFIG[3].value),
springConfigs[3],
);
const metricValues = [metricValue0, metricValue1, metricValue2, metricValue3];
// Parallax sutil em px, sinal alternado por métrica — assinatura "reage ao ponteiro"
const pointerShiftX = useTransform(pointerX, [-1, 1], [-8, 8]);
const pointerShiftY = useTransform(pointerY, [-1, 1], [-5, 5]);
const pointerShiftXNeg = useTransform(pointerShiftX, (v) => -v);
const pointerShiftYNeg = useTransform(pointerShiftY, (v) => -v);
const generateVariation = useRef((metric: TelemetryMetric): number => {
const range =
(metric.max ?? metric.value * 1.1) - (metric.min ?? metric.value * 0.9);
const variation = (Math.random() - 0.5) * range * 0.15;
const newValue = metric.value + variation;
return Math.max(
metric.min ?? -Infinity,
Math.min(metric.max ?? Infinity, newValue),
);
});
const updateMetrics = useRef(() => {
setMetrics((prev) =>
prev.map((metric) => ({
...metric,
value: generateVariation.current(metric),
})),
);
});
useEffect(() => {
if (reducedMotion) return;
intervalRef.current = setInterval(updateMetrics.current, UPDATE_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [reducedMotion]);
useEffect(() => {
const handlePointerMove = (e: MouseEvent) => {
const x = (e.clientX / window.innerWidth) * 2 - 1;
const y = -(e.clientY / window.innerHeight) * 2 + 1;
pointerX.set(x);
pointerY.set(y);
};
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [pointerX, pointerY]);
return (
<section
className={cn(
"cinematic-glass border-[rgba(204,255,0,0.18)] bg-[rgba(10,10,10,0.82)] backdrop-blur-[24px] rounded-[var(--r-lg)] p-sp-4 lg:p-sp-5 grid grid-cols-2 gap-sp-4 lg:gap-sp-6",
className,
)}
aria-label="Telemetria operacional em tempo real"
onPointerEnter={() => {
isHovering.current = true;
}}
onPointerLeave={() => {
isHovering.current = false;
}}
>
{METRICS_CONFIG.map((metric, index) => (
<motion.div
key={metric.label}
className="relative flex flex-col items-start gap-1"
style={
reducedMotion
? undefined
: {
x: index % 2 === 0 ? pointerShiftX : pointerShiftXNeg,
y: index % 2 === 0 ? pointerShiftY : pointerShiftYNeg,
}
}
>
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.7)]">
{metric.label}
</div>
<div
className="whitespace-nowrap font-display font-extrabold text-[clamp(1.35rem,2.6vw,1.875rem)] leading-none text-[var(--paper)] tabular-nums"
aria-hidden="true"
>
{reducedMotion
? metric.value.toFixed(metric.precision ?? 0)
: metricValues[index].get().toFixed(metric.precision ?? 0)}
<span className="font-mono text-[11px] font-normal text-[rgba(244,242,237,.58)]">
{metric.unit}
</span>
</div>
<div
className="h-px w-full bg-[linear-gradient(90deg,var(--slash),transparent)] opacity-30"
style={{
transformOrigin: "left center",
transform: reducedMotion
? "scaleX(1)"
: `scaleX(${Math.max(0.15, Math.min(1, metric.value / (metric.max ?? 100)))})`,
}}
aria-hidden="true"
/>
</motion.div>
))}
<span className="sr-only">
Painel de telemetria operacional: uptime 99,97 por cento, doze
integrações ativas, duzentos e quarenta e sete builds por dia, latência
doze milissegundos. Valores decorativos simulando uma sala de controle
real.
</span>
</section>
);
}