fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve

Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:48 -03:00
1 parent 9e2d65a926
commit 204ebd5fa5
49 files changed
+2197 -606

No files matched your search

+27 -13
View File
@@ -7,17 +7,23 @@ import {
useRef,
useState,
} from "react";
import { isLowPowerDevice } from "@/lib/device";
import { isLowPowerDevice, isSoftwareWebGL } from "@/lib/device";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
export const DIVE_QUERY =
"(min-height: 700px) and (prefers-reduced-motion: no-preference)";
/**
* Telas de hero além do mergulho (globals.css: height = 170svh + --hero-dive): 100 do palco preso e 70 em que Projetos
* sobe sobre ele (margin-top de -70svh). O mergulho (progresso 0 a 1) é o resto da altura do hero.
* Telas de hero além do mergulho: 100 do palco preso e o resto em que Projetos sobe sobre ele. Quem manda é o CSS
* (`--hero-tail` em globals.css, a mesma variável da altura do hero e da margem de Projetos); o mergulho (progresso 0
* a 1) é o resto da altura do hero. Sem a variável (a folha de estilos não definiu), não há cauda: só o palco.
*/
export const HERO_TAIL_SCREENS = 1.7;
function heroTailScreens(section: HTMLElement) {
const tail = Number.parseFloat(
getComputedStyle(section).getPropertyValue("--hero-tail"),
);
return Number.isFinite(tail) ? tail : 1;
}
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
const IDLE_SLACK_MS = 20;
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
@@ -37,12 +43,18 @@ const smooth = (a: number, b: number, x: number) => {
return t * t * (3 - 2 * t);
};
/**
* O aparelho abre um contexto WebGL2 de verdade, na GPU. Um WebGL por software (SwiftShader, llvmpipe, Microsoft
* Basic Render) abre, mas desenha a cena a poucos quadros por segundo na CPU: nesse caso vale o hero estático.
*/
function supportsWebGL2() {
if (typeof window.WebGL2RenderingContext === "undefined") return false;
try {
const context = document.createElement("canvas").getContext("webgl2");
context?.getExtension("WEBGL_lose_context")?.loseContext();
return context !== null;
if (!context) return false;
const software = isSoftwareWebGL(context);
context.getExtension("WEBGL_lose_context")?.loseContext();
return !software;
} catch {
return false;
}
@@ -89,11 +101,9 @@ function whenIdle(run: () => void) {
/**
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
* num intervalo ocioso) e só então o Canvas. Devolve `null` se o aparelho não faz WebGL2 ou se a tela foi
* desmontada no meio do caminho.
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
*/
async function loadScene(isAlive: () => boolean) {
if (!supportsWebGL2()) return null;
const scene = await import("./HeroFieldScene");
await nextIdle();
if (!isAlive()) return null;
@@ -113,13 +123,14 @@ function trackDive(
onDone: (done: boolean) => void,
) {
section.dataset.dive = "";
const tail = heroTailScreens(section);
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
// o palco fica preso por (altura - 1 tela); o fim dessa rolagem é de Projetos subindo sobre ele
const span = Math.max(
rect.height - window.innerHeight * HERO_TAIL_SCREENS,
rect.height - window.innerHeight * tail,
window.innerHeight * 0.8,
);
const p = Math.min(1, Math.max(0, -rect.top / span));
@@ -167,8 +178,8 @@ function trackVisibility(
}
/**
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2, sem toque, com mais de
* 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
* sem toque, com mais de 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
@@ -191,12 +202,15 @@ export default function HeroField() {
if (isLowPowerDevice()) return;
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
// WebGL2 só por software (ou nenhum): o hero estático de 100svh, já na hidratação, sem o bloco de rolagem do
// mergulho que depois seria desfeito com a página já rolada
if (!supportsWebGL2()) return;
let alive = true;
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
? trackDive(section, progressRef, setDone)
: undefined;
const stopVisibility = trackVisibility(section, setVisible);
// sem WebGL2, com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
// com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
giveUp.current = () => {
stopDive?.();
if (alive) setFailed(true);