fix(story): harden notebook interaction and fallback

This commit is contained in:
Evandro Fonseca Junior committed 2026-08-31 01:28:43 -03:00
1 parent bb4bf8e7e2
commit f3ed9da777
12 files changed
+344 -57

No files matched your search

+19 -3
View File
@@ -2,13 +2,13 @@
import { motion, type Variants } from "motion/react"; import { motion, type Variants } from "motion/react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene"; import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
import { CV_URL } from "@/lib/site-navigation"; import { CV_URL } from "@/lib/site-navigation";
import { buildWhatsAppUrl } from "@/lib/whatsapp"; import { buildWhatsAppUrl } from "@/lib/whatsapp";
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe"; import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
import MagneticButton from "./cinematic/MagneticButton";
import { HeroStarfield } from "./cinematic/HeroStarfield"; import { HeroStarfield } from "./cinematic/HeroStarfield";
import MagneticButton from "./cinematic/MagneticButton";
import { TelemetryStrip } from "./cinematic/TelemetryStrip"; import { TelemetryStrip } from "./cinematic/TelemetryStrip";
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), { const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
@@ -455,8 +455,10 @@ function SideVisual({
export default function Hero() { export default function Hero() {
const variant = HERO_VARIANTS[0]; const variant = HERO_VARIANTS[0];
const heroRef = useRef<HTMLElement>(null);
const [shouldReduceMotion, setShouldReduceMotion] = useState(false); const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false); const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [isHeroVisible, setIsHeroVisible] = useState(true);
useEffect(() => { useEffect(() => {
const reducedMotion = window.matchMedia( const reducedMotion = window.matchMedia(
@@ -478,6 +480,17 @@ export default function Hero() {
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500); const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, []); }, []);
useEffect(() => {
const hero = heroRef.current;
if (!hero || !("IntersectionObserver" in window)) return undefined;
const observer = new IntersectionObserver(
([entry]) => setIsHeroVisible(entry?.isIntersecting ?? false),
{ threshold: 0 },
);
observer.observe(hero);
return () => observer.disconnect();
}, []);
const handleWhatsAppClick = () => { const handleWhatsAppClick = () => {
window.open( window.open(
@@ -532,11 +545,14 @@ export default function Hero() {
return ( return (
<section <section
ref={heroRef}
id="home" id="home"
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]" className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
data-hero-variant={variant.id} data-hero-variant={variant.id}
> >
{shouldRenderHeroScene && <HeroScene variant={variant.scene} />} {shouldRenderHeroScene && (
<HeroScene isVisible={isHeroVisible} variant={variant.scene} />
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" /> <div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" /> <div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
+8 -1
View File
@@ -103,7 +103,13 @@ function CameraRig() {
return null; return null;
} }
export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) { export default function HeroScene({
variant,
isVisible = true,
}: {
variant?: HeroSceneVariant;
isVisible?: boolean;
}) {
void variant; void variant;
return ( return (
<div <div
@@ -113,6 +119,7 @@ export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
<Canvas <Canvas
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }} camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
dpr={[1, 1.5]} dpr={[1, 1.5]}
frameloop={isVisible ? "always" : "never"}
gl={{ gl={{
antialias: false, antialias: false,
alpha: true, alpha: true,
@@ -38,8 +38,29 @@ describe("NotebookBrowser", () => {
expect(screen.getByRole("tabpanel")).toHaveAccessibleName( expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SC Plus Aquicultura/i, /SC Plus Aquicultura/i,
); );
expect(screen.getByRole("status")).toHaveTextContent(
"Projeto ativo: SC Plus Aquicultura",
);
}); });
it("preloads the next screenshot and keeps a short outgoing crossfade", () => {
const view = renderBrowser();
expect(
view.container.querySelector(".notebook-screenshot-preload"),
).toBeInTheDocument();
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
expect(
view.container.querySelector(".notebook-screenshot-outgoing"),
).toBeInTheDocument();
});
it("moves between tabs with arrow keys and Home/End", async () => { it("moves between tabs with arrow keys and Home/End", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSelectTab = jest.fn(); const onSelectTab = jest.fn();
@@ -56,6 +77,26 @@ describe("NotebookBrowser", () => {
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0); expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1); expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
}); });
it("moves DOM focus with the active tab", async () => {
const user = userEvent.setup();
const tabs =
renderBrowser().container.querySelectorAll<HTMLButtonElement>(
'[role="tab"]',
);
tabs[0].focus();
await user.keyboard("{ArrowRight}");
expect(document.activeElement).toBe(tabs[1]);
});
it("removes the closed browser from the accessibility tree", () => {
renderBrowser({ interactive: false });
const browser = screen.getByTestId("notebook-browser");
expect(browser).toHaveAttribute("aria-hidden", "true");
expect(browser).toHaveAttribute("inert");
});
it("bounds arrow navigation at the first and last tab", async () => { it("bounds arrow navigation at the first and last tab", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
+62 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { NotebookProject } from "./notebookProjects"; import type { NotebookProject } from "./notebookProjects";
import type { NotebookFrame } from "./notebookTimeline"; import type { NotebookFrame } from "./notebookTimeline";
@@ -12,6 +13,7 @@ export type NotebookBrowserProps = {
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking" "cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>; >;
onSelectTab: (index: number) => void; onSelectTab: (index: number) => void;
interactive?: boolean;
}; };
function clampIndex(index: number, length: number): number { function clampIndex(index: number, length: number): number {
@@ -28,10 +30,35 @@ export default function NotebookBrowser({
activeIndex, activeIndex,
frame, frame,
onSelectTab, onSelectTab,
interactive = true,
}: NotebookBrowserProps) { }: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length); const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject = const selectedProject =
selectedIndex >= 0 ? projects[selectedIndex] : undefined; selectedIndex >= 0 ? projects[selectedIndex] : undefined;
const previousIndexRef = useRef(selectedIndex);
const [outgoingIndex, setOutgoingIndex] = useState<number | null>(null);
useEffect(() => {
const previousIndex = previousIndexRef.current;
if (previousIndex === selectedIndex) return undefined;
previousIndexRef.current = selectedIndex;
setOutgoingIndex(previousIndex >= 0 ? previousIndex : null);
const timeout = window.setTimeout(() => setOutgoingIndex(null), 180);
return () => window.clearTimeout(timeout);
}, [selectedIndex]);
const outgoingProject =
outgoingIndex !== null ? projects[outgoingIndex] : undefined;
const nextProject =
selectedIndex >= 0 && projects.length > 1
? projects[(selectedIndex + 1) % projects.length]
: undefined;
const interactionProps = interactive
? {}
: {
"aria-hidden": true,
inert: true,
};
function handleTabKeyDown( function handleTabKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>, event: React.KeyboardEvent<HTMLButtonElement>,
@@ -52,12 +79,16 @@ export default function NotebookBrowser({
if (nextIndex === null) return; if (nextIndex === null) return;
event.preventDefault(); event.preventDefault();
event.currentTarget.parentElement
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus();
onSelectTab(nextIndex); onSelectTab(nextIndex);
} }
if (!selectedProject) { if (!selectedProject) {
return ( return (
<div <div
{...interactionProps}
className="notebook-browser" className="notebook-browser"
data-testid="notebook-browser" data-testid="notebook-browser"
data-empty data-empty
@@ -71,7 +102,11 @@ export default function NotebookBrowser({
const panelId = `notebook-panel-${selectedId}`; const panelId = `notebook-panel-${selectedId}`;
return ( return (
<div className="notebook-browser" data-testid="notebook-browser"> <div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
>
<div className="notebook-browser-bar" aria-hidden="true"> <div className="notebook-browser-bar" aria-hidden="true">
<span className="notebook-browser-dot" /> <span className="notebook-browser-dot" />
<span className="notebook-browser-dot" /> <span className="notebook-browser-dot" />
@@ -89,6 +124,9 @@ export default function NotebookBrowser({
{String(projects.length).padStart(2, "0")} {String(projects.length).padStart(2, "0")}
</span> </span>
</div> </div>
<output className="sr-only" aria-live="polite">
Projeto ativo: {selectedProject.title}
</output>
<div <div
className="notebook-tablist" className="notebook-tablist"
@@ -145,14 +183,36 @@ export default function NotebookBrowser({
</div> </div>
<div className="notebook-screenshot-wrap"> <div className="notebook-screenshot-wrap">
{outgoingProject ? (
<Image
alt=""
aria-hidden="true"
className="notebook-screenshot notebook-screenshot-outgoing"
fill
sizes="(min-width: 681px) 60vw, 100vw"
src={outgoingProject.screenshotSrc}
unoptimized
/>
) : null}
<Image <Image
alt={`Captura de tela de ${selectedProject.title}`} alt={`Captura de tela de ${selectedProject.title}`}
className="notebook-screenshot" className="notebook-screenshot notebook-screenshot-current"
fill fill
sizes="(min-width: 681px) 60vw, 100vw" sizes="(min-width: 681px) 60vw, 100vw"
src={selectedProject.screenshotSrc} src={selectedProject.screenshotSrc}
unoptimized unoptimized
/> />
{nextProject ? (
<Image
alt=""
aria-hidden="true"
className="notebook-screenshot-preload"
fill
sizes="1px"
src={nextProject.screenshotSrc}
unoptimized
/>
) : null}
{frame.cursorVisible ? ( {frame.cursorVisible ? (
<span <span
aria-hidden="true" aria-hidden="true"
@@ -72,15 +72,17 @@ describe("NotebookStage3D", () => {
it("returns usable browser content when the GLB boundary fails", () => { it("returns usable browser content when the GLB boundary fails", () => {
const error = new Error("GLB failed"); const error = new Error("GLB failed");
const onStageError = jest.fn();
mockedUseGLTF.mockImplementation(() => { mockedUseGLTF.mockImplementation(() => {
throw error; throw error;
}); });
render(<NotebookStage3D {...props} />); render(<NotebookStage3D {...props} onStageError={onStageError} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect( expect(
screen.getByRole("link", { name: /abrir projeto/i }), screen.getByRole("link", { name: /abrir projeto/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument(); expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
expect(onStageError).toHaveBeenCalledTimes(1);
}); });
}); });
+18 -12
View File
@@ -27,6 +27,7 @@ export type NotebookStage3DProps = {
activeIndex: number; activeIndex: number;
onSelectTab: (index: number) => void; onSelectTab: (index: number) => void;
isVisible: boolean; isVisible: boolean;
onStageError?: () => void;
}; };
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">; type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
@@ -43,13 +44,13 @@ const OPEN_LID_ANGLE = 112;
function createMaterials(): MaterialMap { function createMaterials(): MaterialMap {
return { return {
body: new THREE.MeshStandardMaterial({ body: new THREE.MeshStandardMaterial({
color: "#102020", color: "#183333",
metalness: 0.78, metalness: 0.78,
roughness: 0.3, roughness: 0.3,
transparent: true, transparent: true,
}), }),
frame: new THREE.MeshStandardMaterial({ frame: new THREE.MeshStandardMaterial({
color: "#294345", color: "#3f6264",
metalness: 0.64, metalness: 0.64,
roughness: 0.38, roughness: 0.38,
transparent: true, transparent: true,
@@ -79,16 +80,17 @@ function cloneNotebookScene(scene: THREE.Object3D): ClonedNotebookScene {
meshes.push(object); meshes.push(object);
object.geometry = object.geometry.clone(); object.geometry = object.geometry.clone();
const sourceMaterial = Array.isArray(object.material) const mapMaterial = (sourceMaterial: THREE.Material) => {
? object.material[0] const materialName = sourceMaterial.name;
: object.material; return materialName === "metal"
const materialName = sourceMaterial?.name ?? "";
object.material =
materialName === "metal"
? materials.screen ? materials.screen
: materialName === "metalMedium" : materialName === "metalMedium"
? materials.frame ? materials.frame
: materials.body; : materials.body;
};
object.material = Array.isArray(object.material)
? object.material.map(mapMaterial)
: mapMaterial(object.material);
object.frustumCulled = false; object.frustumCulled = false;
}); });
@@ -139,7 +141,7 @@ function ScreenSurface({
return ( return (
<Html <Html
center center
distanceFactor={4.6} distanceFactor={0.52}
position={[-0.132, 0.087, 0.081]} position={[-0.132, 0.087, 0.081]}
transform transform
zIndexRange={[20, 0]} zIndexRange={[20, 0]}
@@ -151,6 +153,7 @@ function ScreenSurface({
<NotebookBrowser <NotebookBrowser
activeIndex={activeIndex} activeIndex={activeIndex}
frame={frame} frame={frame}
interactive={frame.screenOpacity > 0.05}
onSelectTab={onSelectTab} onSelectTab={onSelectTab}
projects={projects} projects={projects}
/> />
@@ -214,15 +217,14 @@ function NotebookScene({
{base && lid ? ( {base && lid ? (
<> <>
<primitive object={base} /> <primitive object={base} />
<group ref={lidRig}> <primitive ref={lidRig} object={lid}>
<primitive object={lid} />
<ScreenSurface <ScreenSurface
activeIndex={activeIndex} activeIndex={activeIndex}
frame={frame} frame={frame}
onSelectTab={onSelectTab} onSelectTab={onSelectTab}
projects={projects} projects={projects}
/> />
</group> </primitive>
</> </>
) : ( ) : (
<> <>
@@ -251,6 +253,10 @@ class NotebookStageBoundary extends Component<
return { failed: true }; return { failed: true };
} }
componentDidCatch() {
this.props.onStageError?.();
}
render(): ReactNode { render(): ReactNode {
if (this.state.failed) { if (this.state.failed) {
return ( return (
+31 -1
View File
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { portfolio } from "@/lib/portfolio-data"; import { portfolio } from "@/lib/portfolio-data";
import NotebookStory, { import NotebookStory, {
canUseNotebook3D, canUseNotebook3D,
getNotebookRunwayVh,
getNotebookSeekProgress, getNotebookSeekProgress,
} from "./NotebookStory"; } from "./NotebookStory";
@@ -88,25 +89,54 @@ describe("NotebookStory", () => {
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
); );
}); });
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => { it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
render(<NotebookStory projects={projects} />); render(<NotebookStory projects={projects} />);
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(), expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
); );
expect(
screen.getByLabelText("Projetos em produção no notebook"),
).toHaveAttribute("data-notebook-runway-vh", "520");
}); });
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
);
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 800,
});
window.dispatchEvent(new Event("resize"));
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
});
it("keeps the static fallback when no mapped projects are available", async () => { it("keeps the static fallback when no mapped projects are available", async () => {
mockMatchMedia(); mockMatchMedia();
render(<NotebookStory projects={[]} />); render(<NotebookStory projects={[]} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(
screen.getByLabelText("Projetos em produção no notebook"),
).toBeInTheDocument();
await waitFor(() => await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
); );
}); });
it("scales the runway from the number of projects", () => {
expect(getNotebookRunwayVh(1)).toBe(400);
expect(getNotebookRunwayVh(4)).toBe(520);
expect(getNotebookRunwayVh(99)).toBe(4320);
});
it("requires desktop hover input without reduced motion", () => { it("requires desktop hover input without reduced motion", () => {
const matchMedia = (query: string) => const matchMedia = (query: string) =>
({ ({
+44 -3
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProject } from "@/lib/portfolio-data"; import type { PortfolioProject } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback"; import NotebookFallback from "./NotebookFallback";
@@ -30,6 +31,14 @@ export function canUseNotebook3D(view: CapabilityWindow): boolean {
); );
} }
export function getNotebookRunwayVh(tabCount: number): number {
const count = Math.max(
1,
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
);
return 360 + count * 40;
}
export function getNotebookSeekProgress( export function getNotebookSeekProgress(
index: number, index: number,
tabCount: number, tabCount: number,
@@ -58,7 +67,28 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current); const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => { useEffect(() => {
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window)); const updateEngine = () => {
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
const mediaQueries = [
window.matchMedia("(prefers-reduced-motion: reduce)"),
window.matchMedia("(pointer: coarse)"),
window.matchMedia("(hover: hover)"),
];
window.addEventListener("resize", updateEngine, { passive: true });
mediaQueries.forEach((mediaQuery) => {
mediaQuery.addEventListener("change", updateEngine);
});
return () => {
window.removeEventListener("resize", updateEngine);
mediaQueries.forEach((mediaQuery) => {
mediaQuery.removeEventListener("change", updateEngine);
});
};
}, [notebookProjects.length]); }, [notebookProjects.length]);
useEffect(() => { useEffect(() => {
@@ -127,6 +157,10 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
}; };
}, [engine, notebookProjects.length]); }, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => {
setEngine(false);
}, []);
const seekToTab = useCallback( const seekToTab = useCallback(
(index: number) => { (index: number) => {
const section = sectionRef.current; const section = sectionRef.current;
@@ -161,7 +195,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
if (!engine) { if (!engine) {
return ( return (
<section <section
aria-label="Projetos em produção" aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-static" className="notebook-story notebook-story-static"
data-testid="notebook-story" data-testid="notebook-story"
id="story" id="story"
@@ -183,12 +217,18 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
return ( return (
<section <section
aria-label="Apresentação em scroll contínuo" aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-runway" className="notebook-story notebook-story-runway"
data-notebook-engine="3d" data-notebook-engine="3d"
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
data-testid="notebook-story" data-testid="notebook-story"
id="story" id="story"
ref={sectionRef} ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
} as CSSProperties
}
> >
<div className="notebook-story-sticky"> <div className="notebook-story-sticky">
<div className="notebook-story-grid"> <div className="notebook-story-grid">
@@ -208,6 +248,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
activeIndex={activeIndex} activeIndex={activeIndex}
isVisible={isVisible} isVisible={isVisible}
onSelectTab={handleSelectTab} onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef} progress={progressRef}
projects={notebookProjects} projects={notebookProjects}
/> />
+16 -21
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
/** /**
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira. * ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
@@ -47,10 +47,8 @@ const TO_VARS = {
}; };
export default function ReelDirector() { export default function ReelDirector() {
const alive = useRef(true);
useEffect(() => { useEffect(() => {
alive.current = true; let cancelled = false;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return undefined; return undefined;
@@ -64,14 +62,14 @@ export default function ReelDirector() {
import("gsap"), import("gsap"),
import("gsap/ScrollTrigger"), import("gsap/ScrollTrigger"),
]); ]);
if (!alive.current) return; if (cancelled) return;
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
const done = new WeakSet<Element>(); const done = new WeakSet<Element>();
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
const scan = () => { const scan = () => {
if (!alive.current) return; if (cancelled) return;
for (const el of document.querySelectorAll<HTMLElement>( for (const el of document.querySelectorAll<HTMLElement>(
"[data-reel]", "[data-reel]",
)) { )) {
@@ -94,24 +92,21 @@ export default function ReelDirector() {
)) { )) {
if (done.has(container)) continue; if (done.has(container)) continue;
done.add(container); done.add(container);
const dir = (container.dataset.reelStagger || "bottom") as Direction; const dir = (container.dataset.reelStagger ||
"bottom") as Direction;
const dist = Number(container.dataset.reelDistance || DISTANCE); const dist = Number(container.dataset.reelDistance || DISTANCE);
const children = Array.from(container.children) as HTMLElement[]; const children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) continue; if (children.length === 0) continue;
gsap.fromTo( gsap.fromTo(children, fromVars(dir, dist), {
children, ...TO_VARS,
fromVars(dir, dist), stagger: { each: 0.09 },
{ scrollTrigger: {
...TO_VARS, trigger: container,
stagger: { each: 0.09 }, start: container.dataset.reelStart || "top 85%",
scrollTrigger: { end: container.dataset.reelEnd || "top 25%",
trigger: container, scrub: 0.6,
start: container.dataset.reelStart || "top 85%",
end: container.dataset.reelEnd || "top 25%",
scrub: 0.6,
},
}, },
); });
} }
}; };
@@ -142,7 +137,7 @@ export default function ReelDirector() {
void boot(); void boot();
return () => { return () => {
alive.current = false; cancelled = true;
window.cancelAnimationFrame(raf1); window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2); window.cancelAnimationFrame(raf2);
window.clearTimeout(timer); window.clearTimeout(timer);
+13 -2
View File
@@ -64,8 +64,8 @@ describe("getNotebookFrame", () => {
}); });
it("emits a deterministic cursor click pulse for each tab", () => { it("emits a deterministic cursor click pulse for each tab", () => {
const first = getNotebookFrame(0.447, 4); const first = getNotebookFrame(0.475, 4);
const later = getNotebookFrame(0.53, 4); const later = getNotebookFrame(0.56, 4);
expect(first.cursorClicking).toBe(true); expect(first.cursorClicking).toBe(true);
expect(later.cursorClicking).toBe(true); expect(later.cursorClicking).toBe(true);
@@ -75,6 +75,17 @@ describe("getNotebookFrame", () => {
expect(first.cursorY).toBeLessThanOrEqual(1); expect(first.cursorY).toBeLessThanOrEqual(1);
}); });
it("moves the cursor between tab targets instead of teleporting", () => {
const previous = getNotebookFrame(0.525, 4);
const moving = getNotebookFrame(0.532, 4);
const target = getNotebookFrame(0.56, 4);
expect(moving.cursorX).toBeGreaterThan(previous.cursorX);
expect(moving.cursorX).toBeLessThan(target.cursorX);
expect(moving.cursorY).toBeLessThan(previous.cursorY);
expect(moving.cursorY).toBeGreaterThan(target.cursorY);
});
it("is deterministic when rewound to the same progress", () => { it("is deterministic when rewound to the same progress", () => {
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4)); expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
}); });
+16 -3
View File
@@ -81,6 +81,7 @@ function getCursorPoint(
progress: number, progress: number,
phase: NotebookPhase, phase: NotebookPhase,
activeTabIndex: number, activeTabIndex: number,
tabCount: number,
): { x: number; y: number } { ): { x: number; y: number } {
if (phase === "cursor-in") { if (phase === "cursor-in") {
const cursorProgress = (progress - 0.34) / 0.1; const cursorProgress = (progress - 0.34) / 0.1;
@@ -94,8 +95,19 @@ function getCursorPoint(
return { x: 0.06, y: 0.88 }; return { x: 0.06, y: 0.88 };
} }
const point = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length]; const target = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
return point; if (phase !== "browse" || activeTabIndex === 0) return target;
const browseProgress =
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
const slotProgress = browseProgress * tabCount - activeTabIndex;
const previous = CURSOR_POINTS[(activeTabIndex - 1) % CURSOR_POINTS.length];
const moveProgress = clamp(slotProgress / 0.36, 0, 1);
return {
x: interpolate(previous.x, target.x, moveProgress),
y: interpolate(previous.y, target.y, moveProgress),
};
} }
function isCursorClicking( function isCursorClicking(
@@ -124,7 +136,7 @@ function isCursorClicking(
const browseProgress = const browseProgress =
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START); (progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
const slotProgress = (browseProgress * tabCount) % 1; const slotProgress = (browseProgress * tabCount) % 1;
return slotProgress < 0.12; return slotProgress >= 0.36 && slotProgress < 0.5;
} }
export function getNotebookFrame( export function getNotebookFrame(
@@ -173,6 +185,7 @@ export function getNotebookFrame(
normalizedProgress, normalizedProgress,
phase, phase,
activeTabIndex, activeTabIndex,
normalizedTabCount,
); );
return { return {
+73 -8
View File
@@ -835,7 +835,7 @@ textarea {
.notebook-story-runway { .notebook-story-runway {
position: relative; position: relative;
min-height: 520vh; min-height: var(--notebook-runway, 520vh);
} }
.notebook-story-sticky { .notebook-story-sticky {
@@ -1023,11 +1023,12 @@ textarea {
.notebook-tab { .notebook-tab {
display: flex; display: flex;
min-width: 0; min-width: 0;
min-height: 44px;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
padding: 14px 10px; padding: 10px;
border: 0; border: 1px solid transparent;
border-bottom: 2px solid transparent; border-bottom-width: 2px;
background: transparent; background: transparent;
color: rgba(244, 242, 237, 0.48); color: rgba(244, 242, 237, 0.48);
cursor: pointer; cursor: pointer;
@@ -1104,20 +1105,39 @@ textarea {
.notebook-panel-copy a { .notebook-panel-copy a {
display: inline-flex; display: inline-flex;
min-height: 44px;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-top: var(--sp-5); margin-top: var(--sp-5);
color: var(--slash); padding: 0 14px;
border: 1px solid var(--slash);
background: var(--slash);
color: var(--ink);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-decoration: none; text-decoration: none;
text-transform: uppercase; text-transform: uppercase;
transition:
background-color 180ms ease,
border-color 180ms ease,
transform 180ms ease;
} }
.notebook-panel-copy a:hover { .notebook-panel-copy a:hover {
color: var(--white); border-color: var(--white);
background: var(--white);
color: var(--ink);
}
.notebook-panel-copy a:active {
transform: scale(0.98);
}
.notebook-panel-copy a:focus-visible {
outline: 2px solid var(--slash);
outline-offset: 3px;
} }
.notebook-screenshot-wrap { .notebook-screenshot-wrap {
@@ -1132,7 +1152,49 @@ textarea {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
}
.notebook-screenshot-current,
.notebook-screenshot-outgoing {
position: absolute;
inset: 0;
}
.notebook-screenshot-current {
z-index: 0;
animation: notebook-screenshot-in 180ms ease both;
}
.notebook-screenshot-outgoing {
z-index: 0;
animation: notebook-screenshot-out 180ms ease both;
}
.notebook-screenshot-wrap img.notebook-screenshot-preload {
position: absolute;
z-index: -1;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
@keyframes notebook-screenshot-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes notebook-screenshot-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
} }
.notebook-cursor { .notebook-cursor {
@@ -1228,7 +1290,10 @@ textarea {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.notebook-tab, .notebook-tab,
.notebook-cursor { .notebook-cursor,
.notebook-screenshot-current,
.notebook-screenshot-outgoing {
transition: none; transition: none;
animation: none;
} }
} }