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]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Opus 5.5 committed 2026-10-05 01:38:41 -03:00
1 parent 6db10ffa67
commit 4c826e938f
17 files changed
+816 -253

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>
+91 -34
View File
@@ -2,7 +2,11 @@
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,
) => void;
/*
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
@@ -20,12 +24,17 @@ type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement)
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) {
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;
}
@@ -45,7 +54,12 @@ const TRANSITIONS: Transition[] = [
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(
prev,
{ autoAlpha: 1 },
{ autoAlpha: 0, duration: 0.4 },
0,
).fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
@@ -68,7 +82,8 @@ const TRANSITIONS: Transition[] = [
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
clipPath: () =>
`circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
ease: "power1.in",
},
0,
@@ -78,12 +93,12 @@ const TRANSITIONS: Transition[] = [
(tl, prev, next) => {
holdPrev(tl, prev);
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" },
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 mais rápido que a rolagem, 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
@@ -102,7 +117,12 @@ const TRANSITIONS: Transition[] = [
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 20 }, 0).fromTo(
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" },
@@ -133,7 +153,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 +165,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);
@@ -162,7 +184,10 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
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: "relative",
zIndex: index === 0 ? 30 : index + 1,
});
});
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
@@ -189,23 +214,34 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
// 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[] = [];
// As posições são lidas a cada quadro (offsetTop é barato com o layout já calculado): guardá-las
// deixava a visibilidade errada quando fontes, imagens ou o bloco "Experiência" mudavam a altura.
let frame = 0;
const syncVisibility = () => {
frame = 0;
const y = window.scrollY;
const tops = scenes.map(pageTop);
scenes.forEach((scene, index) => {
const from = index === 0 ? -Infinity : tops[index] - vh();
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
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 });
syncVisibility();
ScrollTrigger.addEventListener("refresh", queueSync);
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,14 +254,27 @@ 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();
@@ -233,11 +282,16 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
// alvos internos descontam o cabeçalho fixo
const scene = scenes.find((candidate) => candidate.contains(target));
const offset =
Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const top =
!scene || scene === scenes[0]
? 0
: Math.max(pageTop(scene), scene === target ? 0 : pageTop(target) - offset);
: Math.max(
pageTop(scene),
scene === target ? 0 : pageTop(target) - 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,8 +307,11 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", measure);
window.removeEventListener("scroll", syncVisibility);
ScrollTrigger.removeEventListener("refresh", queueSync);
window.removeEventListener("scroll", queueSync);
cancelAnimationFrame(frame);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
for (const scene of scenes) scene.style.visibility = "";
gsap.ticker.remove(tick);
+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,