diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx
index 552ecea..2f8671c 100644
--- a/app/components/Hero.tsx
+++ b/app/components/Hero.tsx
@@ -8,7 +8,7 @@ import { CV_URL } from "@/lib/site-navigation";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
import MagneticButton from "./cinematic/MagneticButton";
-import { ParticleBuildText } from "./cinematic/ParticleBuildText";
+import { HeroStarfield } from "./cinematic/HeroStarfield";
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
@@ -318,8 +318,8 @@ function SideVisual({
if (variant.scene === "command") {
return (
- {/* Partículas decorativas atrás da cena: o h1 real permanece o conteúdo acessível */}
-
+ {/* Starfield decorativo: grade uniforme com jitter cobrindo o card inteiro */}
+
{/* autoRotate off em reduced-motion: globo vira âncora estática */}
diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx
index 836e2ff..8b57e6e 100644
--- a/app/components/Projects.tsx
+++ b/app/components/Projects.tsx
@@ -70,7 +70,7 @@ export default function Projects({
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
-
+
{project.category}
@@ -81,7 +81,12 @@ export default function Projects({
)}
-
+
{project.status}
diff --git a/app/components/cinematic/ParticleBuildText.tsx b/app/components/cinematic/HeroStarfield.tsx
similarity index 53%
rename from app/components/cinematic/ParticleBuildText.tsx
rename to app/components/cinematic/HeroStarfield.tsx
index a153589..b3d8e65 100644
--- a/app/components/cinematic/ParticleBuildText.tsx
+++ b/app/components/cinematic/HeroStarfield.tsx
@@ -1,28 +1,26 @@
-"use client";
+"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;
+// Densidade: ~1 star por STAR_AREA_DIVISOR px² CSS, entre piso e teto absolutos
+const STAR_AREA_DIVISOR = 700;
+const MIN_STARS = 300;
+const MAX_STARS = 850;
const OFFSCREEN_POINTER = -100000;
type Particle = {
- sx: number;
+ sx: number; // origem da assemblei (espalhada) no primeiro load
sy: number;
- tx: number;
+ tx: number; // âncora na grade com jitter
ty: number;
- ox: number;
+ ox: number; // deslocamento acumulado do repel do ponteiro
oy: number;
delay: number;
fillStyle: string;
baseAlpha: number;
- ambient: boolean;
size: number;
phase: number;
speed: number;
@@ -31,10 +29,6 @@ type Particle = {
birth: number;
};
-type TrackingCapableContext = CanvasRenderingContext2D & {
- letterSpacing?: string;
-};
-
type Rgb = [number, number, number];
const FALLBACK_SLASH: Rgb = [204, 255, 0];
@@ -61,27 +55,7 @@ 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 }) {
+export function HeroStarfield() {
const containerRef = useRef
(null);
const canvasRef = useRef(null);
@@ -118,19 +92,47 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
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);
+ // Grade estratificada: uma star por célula com jitter interno → cobertura
+ // uniforme do card, sem os aglomerados/vazios do random puro
+ const grid = {
+ cols: 1,
+ rows: 1,
+ cellW: canvas.width || 1,
+ cellH: canvas.height || 1,
+ };
- const fontsReady =
- typeof document.fonts === "undefined"
- ? Promise.resolve()
- : document.fonts.ready;
+ const jitteredCellPosition = (cell: number): [number, number] => {
+ const cx = cell % grid.cols;
+ const cy = Math.floor(cell / grid.cols);
+ return [
+ (cx + Math.random()) * grid.cellW,
+ (cy + Math.random()) * grid.cellH,
+ ];
+ };
+
+ const makeParticle = (tx: number, ty: number): Particle => {
+ const useSlash = Math.random() < 0.45;
+ const rgb = useSlash ? slashRgb : paperRgb;
+ return {
+ tx,
+ ty,
+ sx: hasAssembled ? tx : (Math.random() * 1.3 - 0.15) * canvas.width,
+ sy: hasAssembled ? ty : (Math.random() * 1.3 - 0.15) * canvas.height,
+ ox: 0,
+ oy: 0,
+ delay: Math.random() * 0.35,
+ fillStyle: `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`,
+ baseAlpha: 0.5 + Math.random() * 0.45,
+ size: (Math.random() < 0.72 ? 2 : 3) * dpr,
+ phase: Math.random() * Math.PI * 2,
+ // deriva lenta: período ~2–5s com amplitude 2,5–5,5px CSS → "levemente"
+ speed: 0.0011 + Math.random() * 0.0016,
+ amp: (2.5 + Math.random() * 3) * dpr,
+ ttl: 4000 + Math.random() * 6000,
+ // birth negativo: estagia os ciclos de vida para o spawn/death já rodar disperso no load
+ birth: -Math.random() * 10000,
+ };
+ };
const sample = () => {
const width = container.clientWidth;
@@ -142,120 +144,33 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
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]);
- }
- }
- }
- // Ambient stars cover the whole card so the background reads as a living
- // starfield even where the globe covers the headline text shape
- const makeParticle = (
- tx: number,
- ty: number,
- ambient: boolean,
- ): Particle => {
- const useSlash = Math.random() < (ambient ? 0.45 : 0.72);
- const rgb = useSlash ? slashRgb : paperRgb;
- return {
- tx,
- ty,
- sx: hasAssembled ? tx : (Math.random() * 1.3 - 0.15) * canvas.width,
- sy: hasAssembled ? ty : (Math.random() * 1.3 - 0.15) * canvas.height,
- ox: 0,
- oy: 0,
- delay: Math.random() * 0.35,
- fillStyle: `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`,
- baseAlpha: ambient
- ? 0.6 + Math.random() * 0.4
- : useSlash
- ? 0.45 + Math.random() * 0.3
- : 0.4 + Math.random() * 0.2,
- ambient,
- size:
- (ambient
- ? Math.random() < 0.7
- ? 2
- : 3
- : Math.random() < 0.85
- ? 1
- : 2) * dpr,
- phase: Math.random() * Math.PI * 2,
- speed: 0.0005 + Math.random() * 0.0007,
- amp: (ambient ? 2 + Math.random() * 4 : 0.6 + Math.random()) * dpr,
- ttl: 4000 + Math.random() * 6000,
- // birth negativo: estagia os ciclos de vida para o spawn/death ja rodar disperso no load
- birth: -Math.random() * 10000,
- };
- };
-
- const desired = Math.round(
+ const count = Math.round(
Math.min(
- MAX_PARTICLES,
- Math.max(MIN_PARTICLES, (width * height) / 180),
+ MAX_STARS,
+ Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR),
),
);
-
- const textParticles = Array.from({ length: desired }, () => {
- const target = targets[Math.floor(Math.random() * targets.length)];
- return makeParticle(target[0], target[1], false);
- });
-
- const ambientCount = Math.round(
- Math.min(520, Math.max(200, (width * height) / 2500)),
+ // células quadradas-ish para a proporção do card
+ grid.cols = Math.max(
+ 1,
+ Math.round(Math.sqrt(count * (canvas.width / canvas.height))),
);
- const ambient: Particle[] = Array.from({ length: ambientCount }, () => {
- const tx = Math.random() * canvas.width;
- const ty = Math.random() * canvas.height;
- return makeParticle(tx, ty, true);
- });
+ grid.rows = Math.max(1, Math.ceil(count / grid.cols));
+ grid.cellW = canvas.width / grid.cols;
+ grid.cellH = canvas.height / grid.rows;
- particles = [...textParticles, ...ambient];
+ // ordem embaralhada das células: cada star ocupa uma célula distinta no spawn
+ const cells = Array.from({ length: grid.cols * grid.rows }, (_, i) => i);
+ for (let i = cells.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const tmp = cells[i];
+ cells[i] = cells[j];
+ cells[j] = tmp;
+ }
+ particles = cells.slice(0, count).map((cell) => {
+ const [tx, ty] = jitteredCellPosition(cell);
+ return makeParticle(tx, ty);
+ });
};
const render = () => {
@@ -280,12 +195,17 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
const baseX = particle.sx + (particle.tx - particle.sx) * eased;
const baseY = particle.sy + (particle.ty - particle.sy) * eased;
+ // wander Lissajous: harmônico principal + secundário em razão
+ // não-inteira → trajetória contínua sem repetição óbvia
+ const t = elapsed * particle.speed;
const driftX =
- Math.sin(elapsed * particle.speed + particle.phase) *
+ (Math.sin(t + particle.phase) +
+ 0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) *
particle.amp *
progress;
const driftY =
- Math.cos(elapsed * particle.speed * 0.9 + particle.phase) *
+ (Math.cos(t * 0.9 + particle.phase) +
+ 0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) *
particle.amp *
progress;
@@ -302,27 +222,27 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
particle.oy *= 0.86;
const fadeIn = Math.min(elapsed / 1200, 1);
- const twinkle = particle.ambient
- ? 0.55 + 0.45 * Math.sin(elapsed * 0.004 + particle.phase)
- : 0.82 + 0.18 * Math.sin(elapsed * particle.speed + particle.phase);
- let lifeFade = 1;
- if (particle.ambient) {
- let life = elapsed - particle.birth;
- if (life > particle.ttl) {
- particle.tx = Math.random() * canvas.width;
- particle.ty = Math.random() * canvas.height;
- particle.sx = particle.tx;
- particle.sy = particle.ty;
- particle.phase = Math.random() * Math.PI * 2;
- particle.baseAlpha = 0.6 + Math.random() * 0.4;
- particle.size = (Math.random() < 0.7 ? 2 : 3) * dpr;
- particle.ttl = 4000 + Math.random() * 6000;
- particle.birth = elapsed;
- life = 0;
- }
- // fade-in de 800ms ao nascer, fade-out nos ultimos 800ms de vida
- lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 1);
+ const twinkle = 0.6 + 0.4 * Math.sin(elapsed * 0.003 + particle.phase);
+ let life = elapsed - particle.birth;
+ if (life > particle.ttl) {
+ // renasce em outra célula da grade → uniforme também entre ciclos
+ const [tx, ty] = jitteredCellPosition(
+ Math.floor(Math.random() * grid.cols * grid.rows),
+ );
+ particle.tx = tx;
+ particle.ty = ty;
+ particle.sx = tx;
+ particle.sy = ty;
+ particle.phase = Math.random() * Math.PI * 2;
+ particle.baseAlpha = 0.5 + Math.random() * 0.45;
+ particle.size = (Math.random() < 0.72 ? 2 : 3) * dpr;
+ particle.ttl = 4000 + Math.random() * 6000;
+ particle.birth = elapsed;
+ life = 0;
}
+ // fade-in de 800ms ao nascer, fade-out nos últimos 800ms de vida
+ const lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 1);
+
ctx.globalAlpha = Math.min(
1,
particle.baseAlpha * fadeIn * twinkle * lifeFade,
@@ -371,8 +291,8 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
}
};
- // Warm-up determinÃstico: corrida entre fontes/layout pode deixar o
- // container com 0px no primeiro sample; re-tenta até existirem partÃculas
+ // Warm-up determinístico: corrida de layout pode deixar o container com
+ // 0px no primeiro sample; re-tenta até existirem partículas
const sampleUntilBuilt = (onBuilt?: () => void) => {
sample();
if (particles.length > 0 || cancelled) {
@@ -414,12 +334,9 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
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;
- sampleUntilBuilt(() => drawStaticFrame());
- });
+ // Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
+ hasAssembled = true;
+ sampleUntilBuilt(() => drawStaticFrame());
return () => {
cancelled = true;
@@ -429,8 +346,8 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
};
}
- // Start determinÃstico: intersecting derivado de geometry no setup (o callback do IO
- // pode chegar tarde/fora de ordem com fontsReady e deixar o loop rAF morto na carga)
+ // Start determinístico: intersecting derivado de geometry no setup (o callback do IO
+ // pode chegar tarde e deixar o loop rAF morto na carga)
const syncIntersecting = () => {
const current = container.getBoundingClientRect();
intersecting =
@@ -440,7 +357,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
current.left < window.innerWidth;
};
syncIntersecting();
- syncLoop();
+ sampleUntilBuilt(() => syncLoop());
const intersectionObserver = new IntersectionObserver((entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
@@ -458,11 +375,6 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
window.addEventListener("scroll", handleScroll, { passive: true });
document.addEventListener("visibilitychange", handleVisibilityChange);
- fontsReady.then(() => {
- if (cancelled) return;
- sampleUntilBuilt(() => syncLoop());
- });
-
return () => {
cancelled = true;
window.cancelAnimationFrame(rafId);
@@ -478,7 +390,7 @@ export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
window.removeEventListener("scroll", handleScroll);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
- }, [text]);
+ }, []);
return (