feat(hero): hero redesenhada, titulo visivel no HTML (LCP) e cena 3D so depois do idle
- titulo fluido (clamp ate 88px) com destaque em 'sob medida', eyebrow mono, seta no CTA e brilho de fundo - numeros reais na hero (anos de experiencia, projetos publicados, demos online) calculados em app/page.tsx a partir dos dados do portfolio - remove o opacity:0 inicial do motion: o texto nasce visivel; a entrada anima so a posicao (CSS) - HeroSystem monta a cena Three.js via requestIdleCallback (timeout 2,5s), depois de pintar e hidratar o essencial - capas decorativas (sem captura) escondidas no celular Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
35a950a9b1
commit
23aeaa2fda
5 files changed
+140
-28
No files matched your search
+54
-23
@@ -1,8 +1,4 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
|
||||||
import HeroSystem from "./cinematic/HeroSystem";
|
import HeroSystem from "./cinematic/HeroSystem";
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
@@ -20,32 +16,43 @@ const SOCIAL_LINKS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Hero() {
|
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
|
||||||
const shouldReduceMotion = useReducedMotion();
|
export type HeroStats = {
|
||||||
|
years?: number;
|
||||||
|
projects?: number;
|
||||||
|
demos?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function statItems(stats?: HeroStats) {
|
||||||
|
if (!stats) return [];
|
||||||
|
return [
|
||||||
|
stats.years ? [String(stats.years), "anos de experiência"] : null,
|
||||||
|
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
|
||||||
|
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
|
||||||
|
].filter((item): item is [string, string] => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||||
|
const items = statItems(stats);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="home"
|
id="home"
|
||||||
className="w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
className="relative w-full overflow-hidden bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{
|
|
||||||
duration: shouldReduceMotion ? 0 : 0.45,
|
|
||||||
ease: "easeOut",
|
|
||||||
}}
|
|
||||||
className="mx-auto grid w-full max-w-[max(1400px,var(--shell))] grid-cols-1 items-center gap-sp-8 px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-10 lg:grid-cols-[minmax(0,53fr)_minmax(0,47fr)] lg:gap-sp-8 lg:px-sp-12 lg:pb-sp-16"
|
|
||||||
>
|
>
|
||||||
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
|
{/* Sem opacity:0 inicial: o título já vem visível no HTML (LCP). A entrada anima só a posição, em CSS. */}
|
||||||
|
<div className="hero-rise relative mx-auto grid w-full max-w-[max(1400px,var(--shell))] grid-cols-1 items-center gap-sp-8 px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-10 lg:grid-cols-[minmax(0,53fr)_minmax(0,47fr)] lg:gap-sp-8 lg:px-sp-12 lg:pb-sp-16">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="mb-sp-5 text-[14px] leading-[1.5] text-[rgba(244,242,237,.72)]">
|
<p className="mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
|
||||||
|
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||||
Evandro Fonseca Junior · Engenharia de software
|
Evandro Fonseca Junior · Engenharia de software
|
||||||
</p>
|
</p>
|
||||||
<h1 className="max-w-[700px] text-balance text-[34px] font-semibold leading-[1.05] tracking-[-0.045em] sm:text-[48px] xl:text-[60px]">
|
<h1 className="max-w-[12.5em] text-balance text-[clamp(34px,1.6rem+2.4vw,88px)] font-semibold leading-[1.04] tracking-[-0.045em]">
|
||||||
Sistemas sob medida para operações que não cabem em soluções
|
Sistemas <span className="text-[var(--slash)]">sob medida</span> para
|
||||||
prontas.
|
operações que não cabem em soluções prontas.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-sp-5 max-w-[600px] text-[16px] leading-[1.55] text-[rgba(244,242,237,.8)] lg:text-[18px]">
|
<p className="mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)]">
|
||||||
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
||||||
entendimento do negócio à arquitetura e à entrega em produção.
|
entendimento do negócio à arquitetura e à entrega em produção.
|
||||||
</p>
|
</p>
|
||||||
@@ -53,9 +60,19 @@ export default function Hero() {
|
|||||||
<div className="mt-sp-6 flex flex-col gap-sp-3 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="mt-sp-6 flex flex-col gap-sp-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/#contact"
|
href="/#contact"
|
||||||
className="inline-flex min-h-12 items-center justify-center bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||||
>
|
>
|
||||||
Conversar sobre meu projeto
|
Conversar sobre meu projeto
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/cases"
|
href="/cases"
|
||||||
@@ -72,6 +89,20 @@ export default function Hero() {
|
|||||||
<HeroSystem />
|
<HeroSystem />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 lg:col-start-1 lg:row-start-2">
|
<div className="min-w-0 lg:col-start-1 lg:row-start-2">
|
||||||
|
{items.length > 0 && (
|
||||||
|
<dl className="mb-sp-6 flex flex-wrap gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-5">
|
||||||
|
{items.map(([value, label]) => (
|
||||||
|
<div key={label} className="flex flex-col-reverse">
|
||||||
|
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-mono text-[clamp(26px,1.2rem+0.9vw,40px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
||||||
{["Sistemas web", "Aplicativos", "Integrações"].map((service) => (
|
{["Sistemas web", "Aplicativos", "Integrações"].map((service) => (
|
||||||
<li key={service} className="flex items-center gap-sp-2">
|
<li key={service} className="flex items-center gap-sp-2">
|
||||||
@@ -101,7 +132,7 @@ export default function Hero() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import Contact from "../Contact";
|
|||||||
import EngineeringWithAI from "../EngineeringWithAI";
|
import EngineeringWithAI from "../EngineeringWithAI";
|
||||||
import Experience from "../Experience";
|
import Experience from "../Experience";
|
||||||
import Footer from "../Footer";
|
import Footer from "../Footer";
|
||||||
import Hero from "../Hero";
|
import Hero, { type HeroStats } from "../Hero";
|
||||||
import LighthouseMetrics from "../LighthouseMetrics";
|
import LighthouseMetrics from "../LighthouseMetrics";
|
||||||
import Projects from "../Projects";
|
import Projects from "../Projects";
|
||||||
import Skills from "../Skills";
|
import Skills from "../Skills";
|
||||||
@@ -25,7 +25,9 @@ export default function CinematicHome({
|
|||||||
projects,
|
projects,
|
||||||
experiences,
|
experiences,
|
||||||
skillCategories,
|
skillCategories,
|
||||||
|
heroStats,
|
||||||
}: {
|
}: {
|
||||||
|
heroStats?: HeroStats;
|
||||||
projects: PortfolioProjectView[];
|
projects: PortfolioProjectView[];
|
||||||
experiences: PortfolioExperience[];
|
experiences: PortfolioExperience[];
|
||||||
skillCategories: PortfolioSkillCategory[];
|
skillCategories: PortfolioSkillCategory[];
|
||||||
@@ -35,7 +37,7 @@ export default function CinematicHome({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||||
<main className="ds-main">
|
<main className="ds-main">
|
||||||
<Hero />
|
<Hero stats={heroStats} />
|
||||||
|
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
||||||
<ScrollVelocityText text="SOFTWARE SOB MEDIDA" direction="left" />
|
<ScrollVelocityText text="SOFTWARE SOB MEDIDA" direction="left" />
|
||||||
|
|||||||
@@ -88,11 +88,36 @@ export default function HeroSystem() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCanRender(supported);
|
mountScene(supported);
|
||||||
|
};
|
||||||
|
// A cena 3D (Three.js) so entra depois que a pagina ficou ociosa: titulo, imagem e botoes pintam e hidratam primeiro.
|
||||||
|
let idle: number | undefined;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const cancelPending = () => {
|
||||||
|
if (idle !== undefined) window.cancelIdleCallback?.(idle);
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
idle = timer = undefined;
|
||||||
|
};
|
||||||
|
const mountScene = (value: boolean) => {
|
||||||
|
cancelPending();
|
||||||
|
if (!value) {
|
||||||
|
setCanRender(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window.requestIdleCallback === "function") {
|
||||||
|
idle = window.requestIdleCallback(() => setCanRender(true), {
|
||||||
|
timeout: 2500,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
timer = setTimeout(() => setCanRender(true), 1200);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
updateCapability();
|
updateCapability();
|
||||||
preference.addEventListener("change", updateCapability);
|
preference.addEventListener("change", updateCapability);
|
||||||
return () => preference.removeEventListener("change", updateCapability);
|
return () => {
|
||||||
|
cancelPending();
|
||||||
|
preference.removeEventListener("change", updateCapability);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -976,6 +976,40 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hero: brilho de fundo e entrada em CSS (so posicao; o texto ja nasce visivel para o LCP) */
|
||||||
|
.hero-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-rise > * {
|
||||||
|
animation: hero-rise 0.8s var(--ease-out) both;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.16s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-rise {
|
||||||
|
from {
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-rise > * {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Capa dos cards de projeto: captura real ou painel gerado em codigo */
|
/* Capa dos cards de projeto: captura real ou painel gerado em codigo */
|
||||||
.ds-cover {
|
.ds-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1021,6 +1055,11 @@ textarea {
|
|||||||
letter-spacing: -0.05em;
|
letter-spacing: -0.05em;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* so decorativa: no celular some, para a pagina nao crescer sem informacao nova */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cinematic card hover effect */
|
/* Cinematic card hover effect */
|
||||||
|
|||||||
+16
-1
@@ -1,10 +1,25 @@
|
|||||||
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||||
|
|
||||||
|
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
|
||||||
|
function yearsOfExperience() {
|
||||||
|
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
|
const projects = await loadFeaturedProjects();
|
||||||
return (
|
return (
|
||||||
<CinematicHome
|
<CinematicHome
|
||||||
projects={await loadFeaturedProjects()}
|
heroStats={{
|
||||||
|
years: yearsOfExperience(),
|
||||||
|
projects: projects.length,
|
||||||
|
demos: projects.filter(
|
||||||
|
(project) =>
|
||||||
|
project.demoUrl && project.demoAvailability !== "unavailable",
|
||||||
|
).length,
|
||||||
|
}}
|
||||||
|
projects={projects}
|
||||||
experiences={portfolio.experiences}
|
experiences={portfolio.experiences}
|
||||||
skillCategories={portfolio.skills.technical}
|
skillCategories={portfolio.skills.technical}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user