perf(movimento): uma transição, 3D leve e vídeo certo por aparelho

Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 03:32:23 -03:00
1 parent 8d07b8c6bb
commit c9a0b80656
48 files changed
+7210 -1241

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
"use client";
import Image from "next/image";
import { type CSSProperties, useId, useRef } from "react";
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import { ProjectActions } from "./ProjectActions";
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
import { useNearViewport } from "./useShowcaseMedia";
function ListItem({
item,
device,
onExpand,
}: {
item: ShowcaseItem;
/** `null` no servidor e até montar: o item mostra só o texto e os links. */
device: Device | null;
onExpand?: () => void;
}) {
const { config, project } = item;
const ref = useRef<HTMLLIElement>(null);
const near = useNearViewport(ref);
const assets = device ? getShowcaseAssets(config.id, device) : null;
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela
const poster =
assets && near ? (
<Image
src={assets.poster}
alt={`Captura de tela de ${project.title}`}
width={assets.width}
height={assets.height}
sizes={device === "mobile" ? "(max-width: 767px) 70vw" : "50vw"}
/>
) : null;
return (
<li
ref={ref}
className="ds-reel-item"
data-device={device ?? undefined}
style={{ "--accent": config.accent } as CSSProperties}
>
{device && (
<div className="ds-reel-item-media">
{device === "mobile" ? (
<PhoneFrame className="ds-reel-item-phone">{poster}</PhoneFrame>
) : (
<BrowserFrame url={displayUrl(project)}>
<div className="ds-reel-item-screen">{poster}</div>
</BrowserFrame>
)}
</div>
)}
<div className="ds-reel-caption">
<p className="ds-kicker">
{project.category} · {project.status}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
<ProjectActions item={item} onExpand={onExpand} />
</div>
</li>
);
}
/**
* Projetos em lista simples: sem rolagem presa, sem vídeo que acompanha a rolagem. No celular (abaixo de 768 px)
* é um carrossel por toque (rolagem horizontal com encaixe, o próximo projeto aparece pela borda); em telas
* maiores, uma grade. Serve também a quem pede menos movimento e a janelas baixas demais para a vitrine fixa.
*
* É o que o servidor entrega (texto e links, sem mídia): sem JavaScript os projetos continuam todos ali, e
* `pending` esconde a lista por CSS só onde a vitrine fixa vai assumir o lugar (largura e altura de desktop).
* Pôster e "Ampliar vídeo" entram depois que o navegador monta; vídeo, só dentro da janela ampliada.
*/
export function ProjectList({
items,
device,
pending = false,
}: {
items: ShowcaseItem[];
device: Device | null;
pending?: boolean;
}) {
const open = useShowcaseOpen();
const headingId = useId();
return (
<section
className="ds-reel-list"
aria-labelledby={headingId}
data-pending={pending ? "" : undefined}
>
<h2 id={headingId} className="sr-only">
Projetos de clientes
</h2>
<ul className="ds-reel-items">
{items.map((item) => (
<ListItem
key={item.config.id}
item={item}
device={device}
onExpand={device ? () => open(item.config.id) : undefined}
/>
))}
</ul>
</section>
);
}