CI / verify (push) Failing after 5m29s
Estado do disco em 14/10/2026, sem filtrar. Inclui as copias .sync-conflict-* do Syncthing de 07/10 e os 15 arquivos que o Syncthing apagou; todo esse conteudo ja existia em outros commits. So havia fora do Git: AGENTS.md, CLAUDE.md e a alteracao em data/project-health.json.
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef } from "react";
|
||
|
||
const MAX_DPR = 1.5;
|
||
const ASSEMBLE_MS = 2000;
|
||
const REPEL_RADIUS_PX = 80;
|
||
// 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; // origem da assemblei (espalhada) no primeiro load
|
||
sy: number;
|
||
tx: number; // âncora na grade com jitter
|
||
ty: number;
|
||
ox: number; // deslocamento acumulado do repel do ponteiro
|
||
oy: number;
|
||
delay: number;
|
||
fillStyle: string;
|
||
baseAlpha: number;
|
||
size: number;
|
||
phase: number;
|
||
speed: number;
|
||
amp: number;
|
||
ttl: number;
|
||
birth: number;
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
export function HeroStarfield() {
|
||
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 sampleRetryTimer = 0;
|
||
let sampleAttempts = 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;
|
||
|
||
// 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 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;
|
||
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 count = Math.round(
|
||
Math.min(
|
||
MAX_STARS,
|
||
Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR),
|
||
),
|
||
);
|
||
// células quadradas-ish para a proporção do card
|
||
grid.cols = Math.max(
|
||
1,
|
||
Math.round(Math.sqrt(count * (canvas.width / canvas.height))),
|
||
);
|
||
grid.rows = Math.max(1, Math.ceil(count / grid.cols));
|
||
grid.cellW = canvas.width / grid.cols;
|
||
grid.cellH = canvas.height / grid.rows;
|
||
|
||
// 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 = () => {
|
||
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;
|
||
|
||
// 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(t + particle.phase) +
|
||
0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) *
|
||
particle.amp *
|
||
progress;
|
||
const driftY =
|
||
(Math.cos(t * 0.9 + particle.phase) +
|
||
0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) *
|
||
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;
|
||
|
||
const fadeIn = Math.min(elapsed / 1200, 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,
|
||
);
|
||
ctx.fillStyle = particle.fillStyle;
|
||
ctx.fillRect(
|
||
baseX + driftX + particle.ox,
|
||
baseY + driftY + particle.oy,
|
||
particle.size,
|
||
particle.size,
|
||
);
|
||
}
|
||
|
||
ctx.globalAlpha = 1;
|
||
|
||
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.fillStyle;
|
||
ctx.globalAlpha = particle.baseAlpha;
|
||
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
};
|
||
|
||
const tick = (now: number) => {
|
||
if (!running) return;
|
||
elapsed += Math.min(now - lastTime, 50);
|
||
lastTime = now;
|
||
render();
|
||
rafId = window.requestAnimationFrame(tick);
|
||
};
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
// 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) {
|
||
onBuilt?.();
|
||
return;
|
||
}
|
||
if (sampleAttempts++ > 50) return;
|
||
sampleRetryTimer = window.setTimeout(
|
||
() => sampleUntilBuilt(onBuilt),
|
||
120,
|
||
);
|
||
};
|
||
|
||
const scheduleResample = () => {
|
||
window.clearTimeout(resampleTimer);
|
||
resampleTimer = window.setTimeout(() => {
|
||
sampleUntilBuilt(() => {
|
||
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
|
||
hasAssembled = true;
|
||
sampleUntilBuilt(() => drawStaticFrame());
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(resampleTimer);
|
||
window.clearTimeout(sampleRetryTimer);
|
||
resizeObserver.disconnect();
|
||
};
|
||
}
|
||
|
||
// 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 =
|
||
current.bottom > 0 &&
|
||
current.right > 0 &&
|
||
current.top < window.innerHeight &&
|
||
current.left < window.innerWidth;
|
||
};
|
||
syncIntersecting();
|
||
sampleUntilBuilt(() => syncLoop());
|
||
|
||
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);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.cancelAnimationFrame(rafId);
|
||
window.clearTimeout(resampleTimer);
|
||
window.clearTimeout(sampleRetryTimer);
|
||
resizeObserver.disconnect();
|
||
intersectionObserver.disconnect();
|
||
window.removeEventListener("pointermove", handlePointerMove);
|
||
document.documentElement.removeEventListener(
|
||
"pointerleave",
|
||
handlePointerLeave,
|
||
);
|
||
window.removeEventListener("scroll", handleScroll);
|
||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0"
|
||
>
|
||
<canvas ref={canvasRef} className="h-full w-full" />
|
||
</div>
|
||
);
|
||
}
|