feat: refine portfolio showcase, services and quality lab
CI / verify (push) Failing after 5m9s

Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
This commit is contained in:
Evandro Fonseca Junior committed 2026-09-05 14:20:54 -03:00
1 parent e2b20bc699
commit 8b12e51bc5
35 files changed
+2359 -1299

No files matched your search

+85 -57
View File
@@ -6,19 +6,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
import { getNotebookFrame } from "./notebookTimeline";
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
ssr: false,
});
const BROWSE_START = 0.44;
const BROWSE_END = 0.78;
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProjectView[];
onLayoutReady?: () => void;
};
export function canUseNotebook3D(view: CapabilityWindow): boolean {
@@ -36,18 +34,7 @@ export function getNotebookRunwayVh(tabCount: number): number {
1,
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
);
return 360 + count * 40;
}
export function getNotebookSeekProgress(
index: number,
tabCount: number,
): number {
const count = Math.max(0, Math.floor(tabCount));
if (count < 1) return BROWSE_START;
const safeIndex = Math.min(Math.max(Math.floor(index), 0), count - 1);
const slot = (BROWSE_END - BROWSE_START) / count;
return BROWSE_START + slot * (safeIndex + 0.5);
return 220 + count * 85;
}
function clampIndex(index: number, length: number): number {
@@ -55,13 +42,22 @@ function clampIndex(index: number, length: number): number {
return Math.min(Math.max(index, 0), length - 1);
}
export default function NotebookStory({ projects }: NotebookStoryProps) {
export default function NotebookStory({
projects,
onLayoutReady,
}: NotebookStoryProps) {
const notebookProjects = useMemo(
() => selectNotebookProjects(projects),
[projects],
);
const sectionRef = useRef<HTMLElement>(null);
const progressRef = useRef(0);
const requestRenderRef = useRef<(() => void) | null>(null);
const seekRef = useRef<((progress: number) => void) | null>(null);
const layoutReadyRef = useRef(onLayoutReady);
const failedRef = useRef(false);
const frameRef = useRef(getNotebookFrame(0, notebookProjects.length));
const [capabilityResolved, setCapabilityResolved] = useState(false);
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
const [engine, setEngine] = useState(false);
const [sectionIntersects, setSectionIntersects] = useState(false);
@@ -69,12 +65,21 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
() => typeof document === "undefined" || !document.hidden,
);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => {
layoutReadyRef.current = onLayoutReady;
}, [onLayoutReady]);
useEffect(() => {
if (!engine && capabilityResolved) layoutReadyRef.current?.();
}, [engine, capabilityResolved]);
useEffect(() => {
const updateEngine = () => {
const nextEngine =
notebookProjects.length > 0 && canUseNotebook3D(window);
!failedRef.current &&
notebookProjects.length > 0 &&
canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine);
setCapabilityResolved(true);
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
@@ -108,7 +113,9 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
useEffect(() => {
const section = sectionRef.current;
if (!engine || !section || !("IntersectionObserver" in window)) {
if (!engine || !section) return undefined;
if (!("IntersectionObserver" in window)) {
setSectionIntersects(true);
return undefined;
}
@@ -139,33 +146,58 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
gsap.registerPlugin(ScrollTrigger);
const context = gsap.context(() => {
const driver = { value: 0 };
const applyProgress = () => {
progressRef.current = driver.value;
const nextIndex = getNotebookFrame(
driver.value,
notebookProjects.length,
frameRef.current,
).activeTabIndex;
if (nextIndex !== activeIndexRef.current) {
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
}
requestRenderRef.current?.();
};
const timeline = gsap.timeline({
defaults: { ease: "none" },
onUpdate: applyProgress,
scrollTrigger: {
trigger: sectionRoot,
start: "top top",
end: "bottom bottom",
scrub: 0.85,
scrub: true,
invalidateOnRefresh: true,
onUpdate: (self) => {
progressRef.current = self.progress;
const nextIndex = getNotebookFrame(
self.progress,
notebookProjects.length,
).activeTabIndex;
if (nextIndex >= 0 && nextIndex !== activeIndexRef.current) {
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
}
},
onRefresh: () => layoutReadyRef.current?.(),
},
});
timeline.to(driver, { duration: 1, value: 1 });
seekRef.current = (progress) => {
const trigger = timeline.scrollTrigger;
if (!trigger) return;
// Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs.
window.scrollTo({
top: trigger.start + (trigger.end - trigger.start) * progress,
behavior: "instant",
});
ScrollTrigger.update();
timeline.progress(progress);
applyProgress();
};
timeline.scrollTrigger?.refresh();
}, sectionRoot);
cleanup = () => context.revert();
cleanup = () => {
seekRef.current = null;
context.revert();
};
}
void setupTimeline();
void setupTimeline().catch(() => {
if (!cancelled) {
failedRef.current = true;
setEngine(false);
}
});
return () => {
cancelled = true;
cleanup();
@@ -173,47 +205,38 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
}, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => {
failedRef.current = true;
setEngine(false);
}, []);
const seekToTab = useCallback(
(index: number) => {
const section = sectionRef.current;
if (!section || notebookProjects.length < 1) return;
const rect = section.getBoundingClientRect();
const sectionTop = (window.scrollY || window.pageYOffset) + rect.top;
const runwayDistance = Math.max(
section.offsetHeight - window.innerHeight,
0,
);
const target =
sectionTop +
runwayDistance *
getNotebookSeekProgress(index, notebookProjects.length);
window.scrollTo({ top: target, behavior: "smooth" });
},
[notebookProjects.length],
);
const handleSelectTab = useCallback(
(index: number) => {
const safeIndex = clampIndex(index, notebookProjects.length);
if (safeIndex < 0) return;
activeIndexRef.current = safeIndex;
setActiveIndex(safeIndex);
seekToTab(safeIndex);
if (engine) {
seekRef.current?.(
getNotebookSeekProgress(safeIndex, notebookProjects.length),
);
}
},
[notebookProjects.length, seekToTab],
[engine, notebookProjects.length],
);
if (!engine) {
return (
<section
aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-static"
className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 0 ? " notebook-story-pending" : ""}`}
data-testid="notebook-story"
id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
} as CSSProperties
}
>
<div className="notebook-story-static-content">
<div className="notebook-story-copy">
@@ -224,7 +247,11 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
tecnologias em produção.
</p>
</div>
<NotebookFallback projects={notebookProjects} />
<NotebookFallback
initialIndex={activeIndex}
onSelectTab={handleSelectTab}
projects={notebookProjects}
/>
</div>
</section>
);
@@ -265,6 +292,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
requestRender={requestRenderRef}
projects={notebookProjects}
/>
</div>