Compare commits

..
Author SHA1 Message Date
Evandro Fonseca JuniorandClaude Opus 5.5 b2f3899dc2 fix(mobile): sem tremido nas transicoes com rolagem rapida no celular
CI / verify (push) Failing after 5m47s
A secao que sai fica presa com position: sticky (o compositor do navegador a
mantem em sincronia com a rolagem) em vez de ser deslocada pelo JavaScript.
No toque, a secao que entra sobe com o dedo: a trava de posicao pelo GSAP
chegava um quadro atrasada na rolagem nativa e tremia. Cada passagem mantem
um efeito proprio sem disputar a posicao. Folga de visibilidade cobre o
quadro de atraso. Posicoes das cenas somam alturas (sticky altera offsetTop).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 03:08:42 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 4c826e938f fix(ci): lint do Biome, Next 16.3.8 (RCE em next/og) e transicoes que se remedem
CI / verify (push) Failing after 5m43s
- Biome: formatacao, ordem de imports, max-width duplicado no cabecalho de
  secao, supressao justificada para nomes de view-transition, ajustes nos
  scripts de auditoria de SEO
- next 16.3.3 -> 16.3.8: GHSA-vcvr-r3jv-pc5j (execucao remota de codigo em
  next/og ImageResponse, usado nas imagens de compartilhamento)
- overrides de brace-expansion (GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p,
  GHSA-q2hr-2g5m-vwhr), dependencia de desenvolvimento via jest
- visibilidade das cenas le as posicoes a cada quadro e o GSAP remede as
  passagens quando a pagina muda de altura (fontes, imagens, Experiencia)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:38:41 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 6db10ffa67 Merge branch 'design/espaco-e-consistencia'
Hero com cena WebGL legivel (tela, integracoes, dados) e mergulho com legendas,
rolagem suave e transicoes de secao ligadas a rolagem (desktop e celular),
transicao entre paginas, remocao da secao do notebook, otimizacoes de
desempenho, SEO/AEO/GEO e prompts de logo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:28:01 -03:00
17 changed files with 926 additions and 312 deletions

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ export async function generateMetadata({
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
locale: "pt_BR",
locale: "pt_BR",
title: caseStudy.title,
description: caseStudy.summary,
url,
+9 -5
View File
@@ -34,13 +34,19 @@ function statItems(stats?: HeroStats) {
}
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" ");
const TITLE =
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
" ",
);
const ACCENT = new Set([1, 2]);
/** O que cada camada da cena significa, em linguagem simples. */
const CAPTIONS = [
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
["As integrações", "Os programas que você já usa conversando entre si, sem retrabalho."],
[
"As integrações",
"Os programas que você já usa conversando entre si, sem retrabalho.",
],
["Os dados", "Suas informações guardadas, organizadas e seguras."],
] as const;
@@ -151,9 +157,7 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
<a
key={link.label}
href={link.href}
target={
link.href.startsWith("http") ? "_blank" : undefined
}
target={link.href.startsWith("http") ? "_blank" : undefined}
rel={
link.href.startsWith("http")
? "noopener noreferrer"
@@ -11,20 +11,30 @@ describe("TextDiffuseReveal", () => {
});
it("keeps the whole sentence readable once for assistive technology", () => {
const { container } = render(<TextDiffuseReveal text="Software feito a partir do seu negócio" />);
const { container } = render(
<TextDiffuseReveal text="Software feito a partir do seu negócio" />,
);
expect(screen.getByText("Software feito a partir do seu negócio")).toHaveClass("sr-only");
expect(
screen.getByText("Software feito a partir do seu negócio"),
).toHaveClass("sr-only");
const words = container.querySelectorAll(".text-reveal-word");
expect(words).toHaveLength(7);
expect(words[0].closest("[aria-hidden='true']")).not.toBeNull();
});
it("marks the title as shown only after it enters the viewport", () => {
const { container, rerender } = render(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).not.toHaveAttribute("data-shown");
const { container, rerender } = render(
<TextDiffuseReveal text="Reveal me" />,
);
expect(container.querySelector(".text-reveal")).not.toHaveAttribute(
"data-shown",
);
mockInView = true;
rerender(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).toHaveAttribute("data-shown");
expect(container.querySelector(".text-reveal")).toHaveAttribute(
"data-shown",
);
});
});
@@ -45,7 +45,11 @@ export function TextDiffuseReveal({
if (token === "\n") return <br key={key} />;
if (/^\s+$/.test(token)) return " ";
return (
<span key={key} className="text-reveal-word" style={{ "--i": word++ } as CSSProperties}>
<span
key={key}
className="text-reveal-word"
style={{ "--i": word++ } as CSSProperties}
>
{token}
</span>
);
@@ -51,9 +51,7 @@ describe("home fragment navigation", () => {
restoredPosition = options.top ?? restoredPosition;
});
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(restoredPosition).toBe(0);
});
@@ -62,17 +60,13 @@ describe("home fragment navigation", () => {
.spyOn(window, "scrollTo")
.mockImplementation(() => {});
const firstVisit = render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
firstVisit.unmount();
expect(window.history.scrollRestoration).not.toBe("manual");
scrollTo.mockClear();
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(scrollTo).not.toHaveBeenCalled();
expect(window.history.scrollRestoration).not.toBe("manual");
});
@@ -87,9 +81,7 @@ describe("home fragment navigation", () => {
render(<HomeNavigation />);
position = 600;
fireEvent.wheel(window, { deltaY: 600 });
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(position).toBe(600);
});
@@ -99,14 +91,10 @@ describe("home fragment navigation", () => {
const contact = screen.getByRole("region", { name: "Contato" });
expect(contact).not.toHaveFocus();
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(contact).toHaveFocus();
screen.getByRole("button", { name: "Read biography" }).focus();
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(
screen.getByRole("button", { name: "Read biography" }),
).toHaveFocus();
@@ -118,18 +106,14 @@ describe("home fragment navigation", () => {
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
fireEvent.wheel(window, { deltaY: 200 });
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(message).toHaveFocus();
});
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
const link = screen.getByRole("link", { name: "Sobre" });
link.focus();
expect(fireEvent.click(link)).toBe(true);
@@ -151,9 +135,7 @@ describe("home fragment navigation", () => {
window.history.replaceState(null, "", "/#contact");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
fireEvent.click(
screen.getByRole("button", { name: "Establish layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(message).toHaveFocus();
});
});
+1 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import type {
PortfolioExperience,
PortfolioProjectView,
@@ -15,7 +16,6 @@ import LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects";
import Skills from "../Skills";
import { WorkModalities } from "../WorkModalities";
import { useEffect, useRef } from "react";
import ExperienceDisclosure from "./ExperienceDisclosure";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
+7 -2
View File
@@ -48,7 +48,10 @@ export default function HeroField() {
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
const span = Math.max(rect.height - window.innerHeight, window.innerHeight * 0.8);
const span = Math.max(
rect.height - window.innerHeight,
window.innerHeight * 0.8,
);
const p = Math.min(1, Math.max(0, -rect.top / span));
progressRef.current = p;
section.style.setProperty("--hero-p", p.toFixed(4));
@@ -83,7 +86,9 @@ export default function HeroField() {
if (!supportsWebGL2()) return;
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
if (typeof window.requestIdleCallback === "function") {
const id = window.requestIdleCallback(() => setMount(true), { timeout: 2500 });
const id = window.requestIdleCallback(() => setMount(true), {
timeout: 2500,
});
return () => window.cancelIdleCallback(id);
}
const id = setTimeout(() => setMount(true), 1200);
+144 -33
View File
@@ -42,7 +42,13 @@ class FieldBuilder {
alpha: number[] = [];
constructor(private density: number) {}
point(x: number, y: number, z: number, style: PointStyle = {}, to?: [number, number, number]) {
point(
x: number,
y: number,
z: number,
style: PointStyle = {},
to?: [number, number, number],
) {
this.base.push(x, y, z);
this.to.push(...(to ?? [x, y, z]));
this.move.push(to ? 1 : 0);
@@ -50,36 +56,89 @@ class FieldBuilder {
this.size.push(style.size ?? 0.02);
this.alpha.push(style.alpha ?? 0.7);
}
line(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
line(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
for (let i = 0; i <= n; i++) this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
for (let i = 0; i <= n; i++)
this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
}
rect(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
rect(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
this.line(y, x0, z0, x1, z0, step, style);
this.line(y, x1, z0, x1, z1, step, style);
this.line(y, x1, z1, x0, z1, step, style);
this.line(y, x0, z1, x0, z0, step, style);
}
fill(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
fill(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
for (let x = x0; x <= x1 + 1e-6; x += s)
for (let z = z0; z <= z1 + 1e-6; z += s)
this.point(x + (Math.random() - 0.5) * s * 0.3, y, z + (Math.random() - 0.5) * s * 0.3, style);
this.point(
x + (Math.random() - 0.5) * s * 0.3,
y,
z + (Math.random() - 0.5) * s * 0.3,
style,
);
}
ring(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
const n = Math.max(8, Math.round((Math.PI * 2 * r) / (step * this.density)));
ring(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
const n = Math.max(
8,
Math.round((Math.PI * 2 * r) / (step * this.density)),
);
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
}
}
disc(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
for (let rr = r; rr > 0.005; rr -= step * this.density) this.ring(y, cx, cz, rr, step, style);
disc(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
for (let rr = r; rr > 0.005; rr -= step * this.density)
this.ring(y, cx, cz, rr, step, style);
this.point(cx, y, cz, style);
}
/** partículas que correm de a até b (a posição é calculada no shader) */
flow(a: [number, number, number], b: [number, number, number], count: number, style?: PointStyle) {
flow(
a: [number, number, number],
b: [number, number, number],
count: number,
style?: PointStyle,
) {
const n = Math.max(2, Math.round(count / this.density));
for (let i = 0; i < n; i++) this.point(...a, style, b);
}
@@ -91,12 +150,17 @@ function drawScreen(f: FieldBuilder) {
f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
for (const x of [-1.9, -1.72, -1.54]) f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
for (const x of [-1.9, -1.72, -1.54])
f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, { tone: 1, alpha: 0.95, size: 0.022 }); // botão
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, {
tone: 1,
alpha: 0.95,
size: 0.022,
}); // botão
f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
for (const [x0, x1] of [
@@ -127,9 +191,18 @@ function drawIntegrations(f: FieldBuilder) {
const ux = x / len;
const uz = z / len;
// linha da central até o sistema, e mensagens correndo nos dois sentidos
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, { alpha: 0.35 });
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, { tone: 1, alpha: 0.95, size: 0.026 });
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, { alpha: 0.9, size: 0.024 });
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, {
alpha: 0.35,
});
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, {
tone: 1,
alpha: 0.95,
size: 0.026,
});
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, {
alpha: 0.9,
size: 0.024,
});
const [nx, nz] = NODES[(k + 1) % NODES.length];
f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
});
@@ -144,12 +217,15 @@ function drawData(f: FieldBuilder) {
const cols = [x0, -0.9, 0.2, 1.1, x1];
f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
for (let z = z0 + 0.32; z < z1; z += 0.33) f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
for (const x of cols.slice(1, -1)) f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
for (let z = z0 + 0.32; z < z1; z += 0.33)
f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
for (const x of cols.slice(1, -1))
f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
for (let z = z0 + 0.48; z < z1; z += 0.33) {
for (let c = 0; c < cols.length - 1; c++) {
const start = cols[c] + 0.12;
const width = (cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
const width =
(cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
}
}
@@ -173,10 +249,16 @@ function buildField(dense: boolean) {
const s = Math.sqrt(1 - u * u);
const d = 4 + Math.random() * 7;
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
rand.set(
[Math.random(), Math.random(), Math.random(), Math.random()],
n * 4,
);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(f.base, 3));
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(f.base, 3),
);
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
@@ -314,7 +396,12 @@ const LABEL_ANCHORS: [number, Layer, number][] = [
[2, -1, 1.3],
];
function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps) {
function Field({
progressRef,
labelsRef,
onReady,
onError,
}: HeroFieldSceneProps) {
const { gl, camera, size } = useThree();
const group = useRef<THREE.Group>(null);
const dense = useMemo(
@@ -397,11 +484,14 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
const time = frame.clock.elapsedTime;
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
s.progress += ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
s.progress +=
((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
const p = s.progress;
const aspect = size.width / size.height;
const wide = aspect > 1.1;
const tanHalf = Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
const tanHalf = Math.tan(
THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2),
);
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
@@ -416,7 +506,8 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
u.uFade.value = 1 - smooth(0.93, 1, p);
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
u.uScale.value =
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME);
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) *
Math.sqrt(frameDist / FRAME);
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
@@ -425,16 +516,22 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
g.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + s.tilt.x * 0.15 * (1 - face),
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
s.tilt.x * 0.15 * (1 - face),
0,
);
const settle = 1 - smooth(0, 0.28, p);
// em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
g.position.set(wide ? 1.7 * settle : 0, (wide ? -0.1 : -rest * tanHalf * 0.38) * settle, 0);
g.position.set(
wide ? 1.7 * settle : 0,
(wide ? -0.1 : -rest * tanHalf * 0.38) * settle,
0,
);
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
const spread = u.uSpread.value;
const ahead = [spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
const ahead =
[spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
g.updateMatrixWorld();
@@ -445,17 +542,24 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
.applyMatrix4(g.matrixWorld);
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
g.worldToLocal(helpers.hit);
s.mouse.lerp(helpers.target.set(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
s.mouse.lerp(
helpers.target.set(helpers.hit.x, helpers.hit.z),
1 - Math.exp(-dt * 10),
);
}
} else {
s.mouse.lerp(
helpers.target.set(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.1),
helpers.target.set(
Math.sin(time * 0.31) * 1.5,
Math.sin(time * 0.23 + 1.3) * 1.1,
),
1 - Math.exp(-dt * 1.5),
);
}
u.uMouse.value.copy(s.mouse);
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
u.uMouseAmt.value +=
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
const labels = labelsRef.current ?? [];
@@ -463,7 +567,10 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
const label = labels[index];
if (!label) return;
helpers.anchor.set(x, layer * u.uSpread.value, z).applyMatrix4(g.matrixWorld).project(camera);
helpers.anchor
.set(x, layer * u.uSpread.value, z)
.applyMatrix4(g.matrixWorld)
.project(camera);
const px = ((helpers.anchor.x + 1) / 2) * size.width;
const py = ((1 - helpers.anchor.y) / 2) * size.height;
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
@@ -505,7 +612,11 @@ export default function HeroFieldScene(props: HeroFieldSceneProps) {
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
dpr={[1, 1.75]}
frameloop={props.visible ? "always" : "never"}
gl={{ alpha: true, antialias: false, powerPreference: "high-performance" }}
gl={{
alpha: true,
antialias: false,
powerPreference: "high-performance",
}}
>
<Field {...props} />
</Canvas>
+201 -93
View File
@@ -2,107 +2,141 @@
import { type RefObject, useEffect } from "react";
type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) => void;
type Transition = (
tl: gsap.core.Timeline,
prev: HTMLElement,
next: HTMLElement,
touch: boolean,
) => void;
/*
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
* da rolagem (rolar para cima volta o "vídeo").
* Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada
* para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada
* "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais.
*
* Quem segura as seções paradas durante uma passagem:
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1);
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
* - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências.
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
*/
const vh = () => window.innerHeight;
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`;
const visibleCenter = (el: HTMLElement) => () =>
`50% ${el.offsetHeight - vh() / 2}px`;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
export function pageTop(el: HTMLElement) {
function pageTop(el: HTMLElement) {
let top = 0;
for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null)
for (
let node: HTMLElement | null = el;
node;
node = node.offsetParent as HTMLElement | null
)
top += node.offsetTop;
return top;
}
// a seção que sai fica parada na tela enquanto a próxima entra
const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
tl.fromTo(prev, { y: 0 }, { y: vh }, 0);
// a seção que entra também fica parada no topo: a transição acontece "no lugar"
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
(tl, prev, next) => {
holdPrev(tl, prev);
(tl, prev, next, touch) => {
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
if (touch) {
tl.fromTo(
next,
{
opacity: 0.3,
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
},
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
0,
);
return;
}
holdNext(tl, next);
const band = (open: number) => () =>
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo(
tl.fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
const center = () => `at 50% ${vh() / 2}px`;
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
(tl, prev, next, touch) => {
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 1.18, opacity: 0.2 },
0,
)
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo(
);
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
const radius = () =>
touch
? Math.hypot(window.innerWidth / 2, vh())
: Math.hypot(window.innerWidth / 2, vh() / 2);
if (!touch) holdNext(tl, next);
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
tl.fromTo(
next,
{ xPercent: 100 },
{ xPercent: 0, ease: "power2.out" },
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${radius()}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece.
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ xPercent: 0, opacity: 1 },
{ xPercent: -35, opacity: 0.3 },
0,
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
},
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
(tl, prev, next) => {
holdPrev(tl, prev);
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
next,
{ yPercent: 0, y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
(tl, prev, next, touch) => {
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
if (!touch)
tl.fromTo(
next,
{ y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
},
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 20 }, 0).fromTo(
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ opacity: 1, xPercent: 0 },
{ opacity: 0.25, xPercent: 20 },
0,
).fromTo(
next,
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
{ xPercent: 0, rotation: 0, ease: "power2.out" },
@@ -110,9 +144,8 @@ const TRANSITIONS: Transition[] = [
);
},
// sobre → contato: o contato cresce do centro, como um zoom de câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
@@ -122,9 +155,9 @@ const TRANSITIONS: Transition[] = [
.fromTo(
next,
{
scale: 0.55,
scale: touch ? 0.8 : 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => `50% ${vh() / 2}px`,
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
},
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
0,
@@ -133,7 +166,8 @@ const TRANSITIONS: Transition[] = [
},
];
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
@@ -144,11 +178,12 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
let cleanup = () => {};
(async () => {
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
import("lenis"),
]);
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
import("lenis"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
@@ -158,12 +193,35 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
const touch = window.matchMedia(
"(hover: none) and (pointer: coarse)",
).matches;
const scenes = Array.from(main.children) as HTMLElement[];
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
const sceneTops = () => {
let top = pageTop(main);
return scenes.map((scene) => {
const current = top;
top += scene.offsetHeight;
return current;
});
};
const stick = () => {
for (const scene of scenes)
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
};
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 });
gsap.set(scene, {
position: "sticky",
zIndex: index === 0 ? 30 : index + 1,
});
});
stick();
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
const next = scenes[index + 1];
@@ -172,8 +230,8 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
defaults: { ease: "none", duration: 1 },
scrollTrigger: {
trigger: next,
start: () => pageTop(next) - vh(),
end: () => pageTop(next),
start: () => sceneTops()[index + 1] - vh(),
end: () => sceneTops()[index + 1],
scrub: true,
invalidateOnRefresh: true,
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
@@ -182,30 +240,48 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
onEnter: () => next.classList.add("is-drawing"),
},
});
transition(tl, prev, next);
transition(tl, prev, next, touch);
});
}, main);
// Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na
// tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior;
// depois não aparece atrás de transparências, e o navegador não pinta o que está coberto.
let tops: number[] = [];
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
let frame = 0;
const syncVisibility = () => {
frame = 0;
const y = window.scrollY;
const tops = sceneTops();
scenes.forEach((scene, index) => {
const from = index === 0 ? -Infinity : tops[index] - vh();
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
const from =
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
const to =
index === scenes.length - 1
? Infinity
: tops[index + 1] + vh() * 0.25;
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility) scene.style.visibility = visibility;
if (scene.style.visibility !== visibility)
scene.style.visibility = visibility;
});
};
const measure = () => {
tops = scenes.map(pageTop);
syncVisibility();
const queueSync = () => {
if (!frame) frame = requestAnimationFrame(syncVisibility);
};
measure();
ScrollTrigger.addEventListener("refresh", measure);
window.addEventListener("scroll", syncVisibility, { passive: true });
const onRefresh = () => {
stick();
queueSync();
};
syncVisibility();
ScrollTrigger.addEventListener("refresh", onRefresh);
window.addEventListener("scroll", queueSync, { passive: true });
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
});
resizeObserver.observe(main);
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
const onClick = (event: MouseEvent) => {
@@ -218,26 +294,52 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
event.altKey
)
return;
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self")) return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (url.origin !== location.origin || url.pathname !== location.pathname || !url.hash)
if (
url.origin !== location.origin ||
url.pathname !== location.pathname ||
!url.hash
)
return;
let target: HTMLElement | null = null;
try {
target = document.getElementById(decodeURIComponent(url.hash.slice(1)));
target = document.getElementById(
decodeURIComponent(url.hash.slice(1)),
);
} catch {}
if (!target) return;
event.preventDefault();
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
// alvos internos descontam o cabeçalho fixo
const scene = scenes.find((candidate) => candidate.contains(target));
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
const index = scenes.findIndex((candidate) =>
candidate.contains(target),
);
const offset =
Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const sceneTop = index < 0 ? 0 : sceneTops()[index];
const top =
!scene || scene === scenes[0]
index <= 0
? 0
: Math.max(pageTop(scene), scene === target ? 0 : pageTop(target) - offset);
: Math.max(
sceneTop,
scenes[index] === target
? 0
: sceneTop +
pageTop(target) -
pageTop(scenes[index]) -
offset,
);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
lenis.scrollTo(top, {
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
@@ -253,10 +355,16 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", measure);
window.removeEventListener("scroll", syncVisibility);
ScrollTrigger.removeEventListener("refresh", onRefresh);
window.removeEventListener("scroll", queueSync);
cancelAnimationFrame(frame);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
for (const scene of scenes) scene.style.visibility = "";
for (const scene of scenes) {
scene.style.visibility = "";
scene.style.top = "";
}
gsap.ticker.remove(tick);
lenis.destroy();
};
+66 -16
View File
@@ -290,9 +290,11 @@ textarea {
flex: 1 1 auto;
min-width: 0;
max-width: 760px;
}
/* Largo: título à esquerda, descrição à direita, alinhados pela base (some a faixa vazia ao lado do título). */
@container (min-width: 880px) {
/* Largo: título à esquerda, descrição à direita, alinhados pela base (some a faixa vazia ao lado do título). */
@container (min-width: 880px) {
.ds-section-meta {
max-width: none;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
@@ -983,7 +985,11 @@ textarea {
inset: 0;
pointer-events: none;
background:
radial-gradient(55% 65% at 78% 38%, rgba(204, 255, 0, 0.11), transparent 70%),
radial-gradient(
55% 65% at 78% 38%,
rgba(204, 255, 0, 0.11),
transparent 70%
),
radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%);
}
@@ -1007,7 +1013,9 @@ textarea {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0 0 / 22px 22px;
background:
radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0
0 / 22px 22px;
mask: radial-gradient(38% 46% at 74% 50%, #000, transparent 75%);
transition: opacity 1.2s var(--ease-out);
}
@@ -1032,11 +1040,20 @@ textarea {
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(10, 10, 10, 0.86) 0%, rgba(10, 10, 10, 0.55) 36%, transparent 62%),
linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%);
linear-gradient(
90deg,
rgba(10, 10, 10, 0.86) 0%,
rgba(10, 10, 10, 0.55) 36%,
transparent 62%
), linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%);
@media (max-width: 1023px) {
background: linear-gradient(0deg, rgba(10, 10, 10, 0.92) 0 30%, rgba(10, 10, 10, 0.5) 65%, rgba(10, 10, 10, 0.7));
background: linear-gradient(
0deg,
rgba(10, 10, 10, 0.92) 0 30%,
rgba(10, 10, 10, 0.5) 65%,
rgba(10, 10, 10, 0.7)
);
}
}
@@ -1158,7 +1175,11 @@ textarea {
pointer-events: none;
/* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */
background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
opacity: clamp(0, min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14), 1);
opacity: clamp(
0,
min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14),
1
);
& li {
--mid: calc((var(--from) + var(--to)) / 2);
@@ -1169,7 +1190,16 @@ textarea {
letter-spacing: -0.035em;
text-align: center;
text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
opacity: clamp(0, min((var(--hero-p) - var(--from)) * 14, (var(--to) - var(--hero-p)) * 14), 1);
opacity: clamp(
0,
min(
(var(--hero-p) - var(--from)) *
14,
(var(--to) - var(--hero-p)) *
14
),
1
);
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
}
@@ -1203,7 +1233,8 @@ textarea {
/* o texto sai de cena enquanto a câmera mergulha: sobe, encolhe e perde o foco */
.hero-content {
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08));
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0)
scale(calc(1 - var(--hero-p) * 0.08));
transform-origin: 0 50%;
opacity: calc(1 - var(--hero-p) * 4.2);
filter: blur(calc(var(--hero-p) * 18px));
@@ -1277,8 +1308,10 @@ html.lenis {
color: var(--paper);
background:
radial-gradient(120% 90% at 100% 0%, var(--slash-dim), transparent 60%),
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 100% 32px,
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 32px 100%,
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 100%
32px,
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 /
32px 100%,
var(--ink);
& .ds-cover-mark {
@@ -1486,16 +1519,25 @@ html.lenis {
aparecem uma única vez, sem acompanhar cada quadro da rolagem */
@media (prefers-reduced-motion: no-preference) {
/* só com as transições ativas (html.lenis): sem o script, o diagrama aparece normalmente */
html.lenis .quality-lab:not(.is-drawing) :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
html.lenis .quality-lab:not(.is-drawing)
:is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
opacity: 0;
}
.quality-lab.is-drawing .quality-blueprint :is(.quality-dimensions path, .quality-browser-frame, .quality-wire, .quality-browser-button) {
.quality-lab.is-drawing
.quality-blueprint
:is(
.quality-dimensions path,
.quality-browser-frame,
.quality-wire,
.quality-browser-button
) {
stroke-dasharray: 1800; /* maior que o maior contorno (moldura: ~1600) */
animation: quality-draw 1.1s var(--ease-out) both;
}
.quality-lab.is-drawing :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
.quality-lab.is-drawing
:is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
animation: quality-fade 0.6s var(--ease-out) both;
}
}
@@ -1786,14 +1828,17 @@ html.lenis {
animation: none;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-group(page) {
animation: none;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-old(page) {
animation: page-out 280ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-new(page) {
animation: page-in 640ms var(--ease-out) 160ms both;
}
@@ -1815,7 +1860,12 @@ html.lenis {
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(page),
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-old(page) {
animation: none;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-new(page) {
animation: none;
}
+1 -1
View File
@@ -45,7 +45,7 @@ export async function generateMetadata({
canonical: url,
},
openGraph: {
locale: "pt_BR",
locale: "pt_BR",
title: service.seoTitle,
description: service.metaDescription,
url,
+3 -1
View File
@@ -75,7 +75,9 @@ it("requires sources for factual statements and resolves every source ID", () =>
for (const id of ["reddit-explorer", "gcc-installer"] as const) {
const { boundaries } = getDetail(id);
expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0);
expect(boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0)).toBe(true);
expect(
boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0),
).toBe(true);
}
});
+6 -2
View File
@@ -11,7 +11,11 @@
"@tailwindcss/oxide",
"sharp",
"unrs-resolver"
]
],
"overrides": {
"brace-expansion@<1.1.21": "1.1.21",
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7"
}
},
"scripts": {
"dev": "next dev --turbopack",
@@ -32,7 +36,7 @@
"lenis": "^1.3.26",
"lucide-react": "0.542.0",
"motion": "12.23.12",
"next": "16.3.3",
"next": "16.3.8",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "3.3.1",
+53 -49
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
brace-expansion@<1.1.21: 1.1.21
brace-expansion@>=2.0.0 <2.1.7: 2.1.7
importers:
.:
@@ -30,8 +34,8 @@ importers:
specifier: 12.23.12
version: 12.23.12([email protected]([email protected]))([email protected])
next:
specifier: 16.3.3
version: 16.3.3(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
specifier: 16.3.8
version: 16.3.8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.0
version: 19.1.0
@@ -706,53 +710,53 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
'@next/[email protected].3':
resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==}
'@next/[email protected].8':
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
'@next/[email protected].3':
resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==}
'@next/[email protected].8':
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/[email protected].3':
resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==}
'@next/[email protected].8':
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/[email protected].3':
resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==}
'@next/[email protected].8':
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/[email protected].3':
resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==}
'@next/[email protected].8':
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/[email protected].3':
resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==}
'@next/[email protected].8':
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/[email protected].3':
resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==}
'@next/[email protected].8':
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/[email protected].3':
resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==}
'@next/[email protected].8':
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/[email protected].3':
resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==}
'@next/[email protected].8':
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1254,11 +1258,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
[email protected]8:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
[email protected].21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
[email protected].4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
[email protected].7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
[email protected]:
resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
@@ -2094,8 +2098,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
[email protected].3:
resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==}
[email protected].8:
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -3433,30 +3437,30 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@next/[email protected].3': {}
'@next/[email protected].8': {}
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@next/[email protected].3':
'@next/[email protected].8':
optional: true
'@pkgjs/[email protected]':
@@ -3950,12 +3954,12 @@ snapshots:
dependencies:
require-from-string: 2.0.2
[email protected]8:
[email protected].21:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
[email protected].4:
[email protected].7:
dependencies:
balanced-match: 1.0.2
@@ -4887,11 +4891,11 @@ snapshots:
[email protected]:
dependencies:
brace-expansion: 1.1.18
brace-expansion: 1.1.21
[email protected]:
dependencies:
brace-expansion: 2.1.4
brace-expansion: 2.1.7
[email protected]: {}
@@ -4925,9 +4929,9 @@ snapshots:
[email protected]: {}
[email protected].3(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
[email protected].8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 16.3.3
'@next/env': 16.3.8
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.21
caniuse-lite: 1.0.30001810
@@ -4936,14 +4940,14 @@ snapshots:
react-dom: 19.1.0([email protected])
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
optionalDependencies:
'@next/swc-darwin-arm64': 16.3.3
'@next/swc-darwin-x64': 16.3.3
'@next/swc-linux-arm64-gnu': 16.3.3
'@next/swc-linux-arm64-musl': 16.3.3
'@next/swc-linux-x64-gnu': 16.3.3
'@next/swc-linux-x64-musl': 16.3.3
'@next/swc-win32-arm64-msvc': 16.3.3
'@next/swc-win32-x64-msvc': 16.3.3
'@next/swc-darwin-arm64': 16.3.8
'@next/swc-darwin-x64': 16.3.8
'@next/swc-linux-arm64-gnu': 16.3.8
'@next/swc-linux-arm64-musl': 16.3.8
'@next/swc-linux-x64-gnu': 16.3.8
'@next/swc-linux-x64-musl': 16.3.8
'@next/swc-win32-arm64-msvc': 16.3.8
'@next/swc-win32-x64-msvc': 16.3.8
sharp: 0.35.4(@types/[email protected])
transitivePeerDependencies:
- '@babel/core'
+13 -2
View File
@@ -155,7 +155,13 @@
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
"technologies": ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
"technologies": [
"Astro",
"MongoDB",
"Mongoose",
"TypeScript",
"Reddit API"
],
"features": [
"Coleta dos posts em alta do r/artificial",
"Upsert por id no MongoDB",
@@ -181,7 +187,12 @@
"subtitle": "Landing page institucional em Next.js 15",
"description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
"technologies": ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
"technologies": [
"Next.js 15",
"React 19",
"Tailwind CSS 4",
"TypeScript"
],
"features": [
"Landing page institucional",
"Metadados e URL canônica",
+377 -67
View File
@@ -21,30 +21,68 @@ const BASE = (args.base ?? "https://evandro.dev.br").replace(/\/$/, "");
const SITE = "https://evandro.dev.br"; // origem canônica declarada no código
const UA = "Mozilla/5.0 (compatible; seo-audit/1.0)";
const AI_BOTS = [
["Googlebot", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"],
["bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"],
["OAI-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot"],
["GPTBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)"],
["ChatGPT-User", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)"],
["Claude-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; [email protected])"],
["ClaudeBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +[email protected])"],
["PerplexityBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"],
[
"Googlebot",
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
],
[
"bingbot",
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
],
[
"OAI-SearchBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot",
],
[
"GPTBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
],
[
"ChatGPT-User",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)",
],
[
"Claude-SearchBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; [email protected])",
],
[
"ClaudeBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; [email protected])",
],
[
"PerplexityBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
],
];
const local = (url) => url.replace(SITE, BASE); // permite auditar localhost com URLs canônicas de produção
const get = async (url, ua = UA, init = {}) => {
const t0 = performance.now();
const res = await fetch(url, { headers: { "user-agent": ua, "accept-encoding": "gzip, br" }, redirect: "manual", ...init });
const res = await fetch(url, {
headers: { "user-agent": ua, "accept-encoding": "gzip, br" },
redirect: "manual",
...init,
});
const body = init.method === "HEAD" ? "" : await res.text();
return { res, body, ms: Math.round(performance.now() - t0) };
};
const attrs = (tag) => {
const out = {};
for (const m of tag.matchAll(/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[m[1].toLowerCase()] = m[2] ?? m[3] ?? "";
for (const m of tag.matchAll(
/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
))
out[m[1].toLowerCase()] = m[2] ?? m[3] ?? "";
return out;
};
const decode = (s) => s.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim();
const decode = (s) =>
s
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#x27;|&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.trim();
const strip = (html) =>
decode(
html
@@ -57,16 +95,25 @@ const words = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
function parsePage(html) {
const metas = [...html.matchAll(/<meta\b[^>]*>/gi)].map((m) => attrs(m[0]));
const meta = (key, val = "name") => metas.find((m) => m[val]?.toLowerCase() === key)?.content;
const meta = (key, val = "name") =>
metas.find((m) => m[val]?.toLowerCase() === key)?.content;
const links = [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => attrs(m[0]));
const headings = [...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)].map((m) => ({ level: +m[1], text: strip(m[2]) }));
const headings = [
...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi),
].map((m) => ({ level: +m[1], text: strip(m[2]) }));
const imgs = [...html.matchAll(/<img\b[^>]*>/gi)].map((m) => attrs(m[0]));
const anchors = [...html.matchAll(/<a\b[^>]*>/gi)].map((m) => attrs(m[0]));
const ld = [...html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
const ld = [
...html.matchAll(
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
),
].map((m) => m[1]);
const main = html.match(/<main\b[\s\S]*?<\/main>/i)?.[0] ?? html;
const firstH1 = main.search(/<h1\b/i);
const afterH1 = firstH1 >= 0 ? main.slice(firstH1) : main;
const firstP = [...afterH1.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => strip(m[1])).find((t) => words(t) >= 8);
const firstP = [...afterH1.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)]
.map((m) => strip(m[1]))
.find((t) => words(t) >= 8);
return {
title: decode(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""),
description: meta("description"),
@@ -74,9 +121,19 @@ function parsePage(html) {
viewport: meta("viewport"),
lang: html.match(/<html[^>]*\blang=["']([^"']+)/i)?.[1],
canonical: links.find((l) => l.rel === "canonical")?.href,
alternates: links.filter((l) => l.rel === "alternate" && l.hreflang).map((l) => l.hreflang),
og: Object.fromEntries(metas.filter((m) => m.property?.startsWith("og:")).map((m) => [m.property, m.content])),
tw: Object.fromEntries(metas.filter((m) => m.name?.startsWith("twitter:")).map((m) => [m.name, m.content])),
alternates: links
.filter((l) => l.rel === "alternate" && l.hreflang)
.map((l) => l.hreflang),
og: Object.fromEntries(
metas
.filter((m) => m.property?.startsWith("og:"))
.map((m) => [m.property, m.content]),
),
tw: Object.fromEntries(
metas
.filter((m) => m.name?.startsWith("twitter:"))
.map((m) => [m.name, m.content]),
),
headings,
imgs,
anchors,
@@ -105,7 +162,13 @@ function jsonLdFacts(blocks) {
if (Array.isArray(o)) return o.forEach(walk);
if (o && typeof o === "object") {
const keys = Object.keys(o);
if (keys.length === 1 && keys[0] === "@id" && !ids.has(o["@id"]) && !String(o["@id"]).startsWith(SITE)) dangling.push(o["@id"]);
if (
keys.length === 1 &&
keys[0] === "@id" &&
!ids.has(o["@id"]) &&
!String(o["@id"]).startsWith(SITE)
)
dangling.push(o["@id"]);
Object.values(o).forEach(walk);
}
};
@@ -114,7 +177,8 @@ function jsonLdFacts(blocks) {
}
const checks = []; // {scope, id, group, ok, detail}
const add = (scope, group, id, ok, detail = "") => checks.push({ scope, group, id, ok: Boolean(ok), detail });
const add = (scope, group, id, ok, detail = "") =>
checks.push({ scope, group, id, ok: Boolean(ok), detail });
async function auditUrl(url, siteTitles, siteDescs) {
const target = local(url);
@@ -122,20 +186,70 @@ async function auditUrl(url, siteTitles, siteDescs) {
const scope = url.replace(SITE, "") || "/";
add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`);
if (res.status !== 200) return null;
add(scope, "técnico", "html-comprimido", /br|gzip/.test(res.headers.get("content-encoding") ?? "") || BASE.startsWith("http://localhost"), res.headers.get("content-encoding") ?? "sem compressão");
add(scope, "técnico", "ttfb-menor-800ms", ms < 800, `${ms} ms (HTML completo)`);
add(
scope,
"técnico",
"html-comprimido",
/br|gzip/.test(res.headers.get("content-encoding") ?? "") ||
BASE.startsWith("http://localhost"),
res.headers.get("content-encoding") ?? "sem compressão",
);
add(
scope,
"técnico",
"ttfb-menor-800ms",
ms < 800,
`${ms} ms (HTML completo)`,
);
const p = parsePage(body);
add(scope, "on-page", "lang-pt-BR", p.lang?.toLowerCase() === "pt-br", p.lang ?? "ausente");
add(
scope,
"on-page",
"lang-pt-BR",
p.lang?.toLowerCase() === "pt-br",
p.lang ?? "ausente",
);
add(scope, "on-page", "viewport", Boolean(p.viewport));
add(scope, "on-page", "title-30-65", p.title.length >= 30 && p.title.length <= 65, `${p.title.length} car.: ${p.title}`);
add(
scope,
"on-page",
"title-30-65",
p.title.length >= 30 && p.title.length <= 65,
`${p.title.length} car.: ${p.title}`,
);
add(scope, "on-page", "title-unico", !siteTitles.has(p.title), p.title);
siteTitles.add(p.title);
add(scope, "on-page", "description-70-165", !!p.description && p.description.length >= 70 && p.description.length <= 165, `${p.description?.length ?? 0} car.`);
add(scope, "on-page", "description-unica", !!p.description && !siteDescs.has(p.description));
add(
scope,
"on-page",
"description-70-165",
!!p.description &&
p.description.length >= 70 &&
p.description.length <= 165,
`${p.description?.length ?? 0} car.`,
);
add(
scope,
"on-page",
"description-unica",
!!p.description && !siteDescs.has(p.description),
);
if (p.description) siteDescs.add(p.description);
add(scope, "on-page", "canonical-propria", p.canonical === url || p.canonical === `${url}/`, p.canonical ?? "ausente");
add(scope, "on-page", "indexavel", !/noindex/i.test(p.robots ?? "") && !/noindex/i.test(res.headers.get("x-robots-tag") ?? ""));
add(
scope,
"on-page",
"canonical-propria",
p.canonical === url || p.canonical === `${url}/`,
p.canonical ?? "ausente",
);
add(
scope,
"on-page",
"indexavel",
!/noindex/i.test(p.robots ?? "") &&
!/noindex/i.test(res.headers.get("x-robots-tag") ?? ""),
);
const h1 = p.headings.filter((h) => h.level === 1);
add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`);
let skipped = false;
@@ -144,57 +258,205 @@ async function auditUrl(url, siteTitles, siteDescs) {
return h.level;
}, 0);
add(scope, "on-page", "hierarquia-de-titulos", !skipped);
add(scope, "on-page", "imagens-com-alt", p.imgs.every((i) => i.alt !== undefined), `${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`);
const internal = p.anchors.filter((a) => a.href && (a.href.startsWith("/") || a.href.startsWith(SITE)) && !a.href.startsWith("//"));
add(scope, "on-page", "links-internos-5+", new Set(internal.map((a) => a.href.split("#")[0])).size >= 5, `${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`);
add(
scope,
"on-page",
"imagens-com-alt",
p.imgs.every((i) => i.alt !== undefined),
`${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`,
);
const internal = p.anchors.filter(
(a) =>
a.href &&
(a.href.startsWith("/") || a.href.startsWith(SITE)) &&
!a.href.startsWith("//"),
);
add(
scope,
"on-page",
"links-internos-5+",
new Set(internal.map((a) => a.href.split("#")[0])).size >= 5,
`${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`,
);
const external = p.anchors.filter((a) => a.target === "_blank");
// noreferrer também implica noopener
add(scope, "on-page", "externos-com-noopener", external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")), `${external.length} links _blank; sem rel: ${external.filter((a) => !/noopener|noreferrer/.test(a.rel ?? "")).map((a) => a.href).slice(0, 2).join(" ")}`);
add(
scope,
"on-page",
"externos-com-noopener",
external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")),
`${external.length} links _blank; sem rel: ${external
.filter((a) => !/noopener|noreferrer/.test(a.rel ?? ""))
.map((a) => a.href)
.slice(0, 2)
.join(" ")}`,
);
const og = p.og, tw = p.tw;
const missingOg = ["og:title", "og:description", "og:url", "og:image", "og:type", "og:site_name", "og:locale"].filter((k) => !og[k]);
add(scope, "social", "og-completo", missingOg.length === 0, missingOg.length ? `faltam ${missingOg.join(",")}` : "");
add(scope, "social", "twitter-card", tw["twitter:card"] === "summary_large_image" && !!tw["twitter:title"] && !!tw["twitter:image"]);
const og = p.og,
tw = p.tw;
const missingOg = [
"og:title",
"og:description",
"og:url",
"og:image",
"og:type",
"og:site_name",
"og:locale",
].filter((k) => !og[k]);
add(
scope,
"social",
"og-completo",
missingOg.length === 0,
missingOg.length ? `faltam ${missingOg.join(",")}` : "",
);
add(
scope,
"social",
"twitter-card",
tw["twitter:card"] === "summary_large_image" &&
!!tw["twitter:title"] &&
!!tw["twitter:image"],
);
if (og["og:image"]) {
const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(() => null);
add(scope, "social", "og-image-acessivel", img?.res.status === 200 && /^image\//.test(img.res.headers.get("content-type") ?? ""), `${img?.res.status} ${img?.res.headers.get("content-type")}`);
const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(
() => null,
);
add(
scope,
"social",
"og-image-acessivel",
img?.res.status === 200 &&
/^image\//.test(img.res.headers.get("content-type") ?? ""),
`${img?.res.status} ${img?.res.headers.get("content-type")}`,
);
}
const ld = jsonLdFacts(p.ld);
add(scope, "dados-estruturados", "json-ld-valido", p.ld.length > 0 && ld.errors.length === 0, ld.errors.join("; ") || `${p.ld.length} blocos`);
add(scope, "dados-estruturados", "referencias-resolvem", ld.dangling.length === 0, ld.dangling.join(", "));
add(scope, "dados-estruturados", "sem-faqpage-nem-localbusiness", !ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)), ld.types.join(","));
if (scope !== "/") add(scope, "dados-estruturados", "breadcrumb", ld.types.includes("BreadcrumbList"));
add(scope, "dados-estruturados", "person-com-sameas", !ld.types.includes("Person") || ld.nodes.some((n) => n["@type"] === "Person" && Array.isArray(n.sameAs) && n.sameAs.length >= 2));
add(
scope,
"dados-estruturados",
"json-ld-valido",
p.ld.length > 0 && ld.errors.length === 0,
ld.errors.join("; ") || `${p.ld.length} blocos`,
);
add(
scope,
"dados-estruturados",
"referencias-resolvem",
ld.dangling.length === 0,
ld.dangling.join(", "),
);
add(
scope,
"dados-estruturados",
"sem-faqpage-nem-localbusiness",
!ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)),
ld.types.join(","),
);
if (scope !== "/")
add(
scope,
"dados-estruturados",
"breadcrumb",
ld.types.includes("BreadcrumbList"),
);
add(
scope,
"dados-estruturados",
"person-com-sameas",
!ld.types.includes("Person") ||
ld.nodes.some(
(n) =>
n["@type"] === "Person" &&
Array.isArray(n.sameAs) &&
n.sameAs.length >= 2,
),
);
add(scope, "aeo-geo", "conteudo-no-html-sem-js", p.wordCount >= 250, `${p.wordCount} palavras no <main> do HTML`);
add(scope, "aeo-geo", "resposta-direta-cedo", p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70, `${p.firstAnswerWords} palavras no 1º parágrafo após o H1`);
const names = new Set(ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name));
add(scope, "aeo-geo", "entidade-autor-consistente", names.size <= 1 && (scope === "/" ? names.size === 1 : true), [...names].join(","));
add(
scope,
"aeo-geo",
"conteudo-no-html-sem-js",
p.wordCount >= 250,
`${p.wordCount} palavras no <main> do HTML`,
);
add(
scope,
"aeo-geo",
"resposta-direta-cedo",
p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70,
`${p.firstAnswerWords} palavras no 1º parágrafo após o H1`,
);
const names = new Set(
ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name),
);
add(
scope,
"aeo-geo",
"entidade-autor-consistente",
names.size <= 1 && (scope === "/" ? names.size === 1 : true),
[...names].join(","),
);
return { scope, p, ld };
}
async function main() {
const sm = await get(`${BASE}/sitemap.xml`);
const urls = [...sm.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
add("site", "técnico", "sitemap-200", sm.res.status === 200 && urls.length > 0, `${urls.length} URLs`);
add(
"site",
"técnico",
"sitemap-200",
sm.res.status === 200 && urls.length > 0,
`${urls.length} URLs`,
);
const robots = await get(`${BASE}/robots.txt`);
add("site", "técnico", "robots-com-sitemap", /sitemap:\s*https?:\/\//i.test(robots.body));
add("site", "técnico", "robots-nao-bloqueia-tudo", !/disallow:\s*\/\s*$/im.test(robots.body));
const home = await get(`${BASE}/`);
add(
"site",
"técnico",
"robots-com-sitemap",
/sitemap:\s*https?:\/\//i.test(robots.body),
);
add(
"site",
"técnico",
"robots-nao-bloqueia-tudo",
!/disallow:\s*\/\s*$/im.test(robots.body),
);
add("site", "técnico", "http-para-https-ou-local", true);
for (const [name, ua] of AI_BOTS) {
const r = await get(`${BASE}/`, ua);
add("site", "aeo-geo", `acesso-${name}`, r.res.status === 200, `HTTP ${r.res.status}`);
add(
"site",
"aeo-geo",
`acesso-${name}`,
r.res.status === 200,
`HTTP ${r.res.status}`,
);
}
// A Cloudflare (Email Address Obfuscation) troca e-mails por /cdn-cgi/l/email-protection para quem é robô: buscadores e IA não veem o contato
const botHome = await get(`${BASE}/`, AI_BOTS[0][1]);
add("site", "aeo-geo", "email-visivel-para-robos", !/email-protection|\[email(&#160;|\s)protected\]/.test(botHome.body), /email-protection/.test(botHome.body) ? "HTML servido a robôs tem e-mail ofuscado" : "");
add(
"site",
"aeo-geo",
"email-visivel-para-robos",
!/email-protection|\[email(&#160;|\s)protected\]/.test(botHome.body),
/email-protection/.test(botHome.body)
? "HTML servido a robôs tem e-mail ofuscado"
: "",
);
const llms = await get(`${BASE}/llms.txt`);
// informativo: a política do projeto exclui llms.txt (nenhum sistema de IA o usa, segundo o Google); ausência é o esperado
add("site", "aeo-geo", "llms-txt-ausente-por-politica", llms.res.status === 404, `HTTP ${llms.res.status}`);
add(
"site",
"aeo-geo",
"llms-txt-ausente-por-politica",
llms.res.status === 404,
`HTTP ${llms.res.status}`,
);
const titles = new Set();
const descs = new Set();
@@ -206,52 +468,100 @@ async function main() {
for (const pg of pages.filter(Boolean))
for (const a of pg.p.anchors) {
const h = a.href?.split("#")[0];
if (h && (h.startsWith("/") && !h.startsWith("//"))) targets.add(h);
if (h?.startsWith("/") && !h.startsWith("//")) targets.add(h);
}
let broken = 0;
const brokenList = [];
for (const t of targets) {
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(() => null);
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(
() => null,
);
if (!r || r.res.status >= 400) {
broken++;
brokenList.push(`${t} (${r?.res.status ?? "erro"})`);
}
}
add("site", "técnico", "sem-links-internos-quebrados", broken === 0, `${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`);
add(
"site",
"técnico",
"sem-links-internos-quebrados",
broken === 0,
`${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`,
);
const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, "")));
const orphanCandidates = [...inSitemap].filter((u) => {
const path = u.replace(SITE, "") || "/";
return path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path);
return (
path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path)
);
});
add("site", "técnico", "sem-paginas-orfas-no-sitemap", orphanCandidates.length === 0, orphanCandidates.join(", "));
add(
"site",
"técnico",
"sem-paginas-orfas-no-sitemap",
orphanCandidates.length === 0,
orphanCandidates.join(", "),
);
// links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado)
const ghLinks = new Set();
for (const pg of pages.filter(Boolean))
for (const a of pg.p.anchors) if (/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")) ghLinks.add(a.href.replace(/\/$/, ""));
for (const a of pg.p.anchors)
if (
/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")
)
ghLinks.add(a.href.replace(/\/$/, ""));
const ghBroken = [];
for (const url of ghLinks) {
const r = await get(url, UA, { method: "HEAD" }).catch(() => null);
if (!r || r.res.status === 404) ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
if (!r || r.res.status === 404)
ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
}
add("site", "técnico", "links-github-acessiveis", ghBroken.length === 0, ghBroken.length ? `404 para visitantes: ${ghBroken.join(", ")}` : `${ghLinks.size} repositórios`);
add(
"site",
"técnico",
"links-github-acessiveis",
ghBroken.length === 0,
ghBroken.length
? `404 para visitantes: ${ghBroken.join(", ")}`
: `${ghLinks.size} repositórios`,
);
const groups = {};
for (const c of checks) {
const g = (groups[c.group] ??= { ok: 0, total: 0 });
groups[c.group] ??= { ok: 0, total: 0 };
const g = groups[c.group];
g.total++;
if (c.ok) g.ok++;
}
const out = { base: BASE, at: new Date().toISOString(), urls: urls.length, groups, checks };
const out = {
base: BASE,
at: new Date().toISOString(),
urls: urls.length,
groups,
checks,
};
if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2));
console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`);
for (const [g, v] of Object.entries(groups)) console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
for (const [g, v] of Object.entries(groups))
console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
const failing = checks.filter((c) => !c.ok);
console.log(`\nFalhas (${failing.length}):`);
const byId = {};
for (const f of failing) (byId[f.id] ??= []).push(f);
for (const [id, list] of Object.entries(byId)) console.log(` - ${id} (${list.length}): ${list.slice(0, 4).map((f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`).join(" | ")}`);
for (const f of failing) {
byId[f.id] ??= [];
byId[f.id].push(f);
}
for (const [id, list] of Object.entries(byId))
console.log(
` - ${id} (${list.length}): ${list
.slice(0, 4)
.map(
(f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`,
)
.join(" | ")}`,
);
}
main().catch((e) => {
+15 -6
View File
@@ -13,23 +13,32 @@ const key = (c) => `${c.scope}::${c.id}`;
const prev = new Map(before.checks.map((c) => [key(c), c]));
const next = new Map(after.checks.map((c) => [key(c), c]));
console.log(`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`);
console.log(
`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`,
);
console.log("grupo antes depois");
for (const g of new Set([...Object.keys(before.groups), ...Object.keys(after.groups)])) {
for (const g of new Set([
...Object.keys(before.groups),
...Object.keys(after.groups),
])) {
const a = before.groups[g] ?? { ok: 0, total: 0 };
const b = after.groups[g] ?? { ok: 0, total: 0 };
console.log(`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`);
console.log(
`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`,
);
}
const fixed = [];
const regressed = [];
for (const [k, c] of next) {
const p = prev.get(k);
if (p && !p.ok && c.ok) fixed.push(c);
if (p && p.ok && !c.ok) regressed.push(c);
if (!p && !c.ok) regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` });
if (p?.ok && !c.ok) regressed.push(c);
if (!p && !c.ok)
regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` });
}
console.log(`\nCorrigidos (${fixed.length}):`);
for (const c of fixed) console.log(` + ${c.scope} ${c.id}`);
console.log(`\nPiorou (${regressed.length}):`);
for (const c of regressed) console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`);
for (const c of regressed)
console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`);
process.exit(regressed.length ? 2 : 0);