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

@@ -38,8 +38,29 @@ describe("NotebookBrowser", () => {
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/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 () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
@@ -56,6 +77,26 @@ describe("NotebookBrowser", () => {
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
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 () => {
const user = userEvent.setup();
+62 -2
View File
@@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { NotebookProject } from "./notebookProjects";
import type { NotebookFrame } from "./notebookTimeline";
@@ -12,6 +13,7 @@ export type NotebookBrowserProps = {
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
onSelectTab: (index: number) => void;
interactive?: boolean;
};
function clampIndex(index: number, length: number): number {
@@ -28,10 +30,35 @@ export default function NotebookBrowser({
activeIndex,
frame,
onSelectTab,
interactive = true,
}: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject =
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(
event: React.KeyboardEvent<HTMLButtonElement>,
@@ -52,12 +79,16 @@ export default function NotebookBrowser({
if (nextIndex === null) return;
event.preventDefault();
event.currentTarget.parentElement
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus();
onSelectTab(nextIndex);
}
if (!selectedProject) {
return (
<div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
data-empty
@@ -71,7 +102,11 @@ export default function NotebookBrowser({
const panelId = `notebook-panel-${selectedId}`;
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">
<span className="notebook-browser-dot" />
<span className="notebook-browser-dot" />
@@ -89,6 +124,9 @@ export default function NotebookBrowser({
{String(projects.length).padStart(2, "0")}
</span>
</div>
<output className="sr-only" aria-live="polite">
Projeto ativo: {selectedProject.title}
</output>
<div
className="notebook-tablist"
@@ -145,14 +183,36 @@ export default function NotebookBrowser({
</div>
<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
alt={`Captura de tela de ${selectedProject.title}`}
className="notebook-screenshot"
className="notebook-screenshot notebook-screenshot-current"
fill
sizes="(min-width: 681px) 60vw, 100vw"
src={selectedProject.screenshotSrc}
unoptimized
/>
{nextProject ? (
<Image
alt=""
aria-hidden="true"
className="notebook-screenshot-preload"
fill
sizes="1px"
src={nextProject.screenshotSrc}
unoptimized
/>
) : null}
{frame.cursorVisible ? (
<span
aria-hidden="true"
@@ -72,15 +72,17 @@ describe("NotebookStage3D", () => {
it("returns usable browser content when the GLB boundary fails", () => {
const error = new Error("GLB failed");
const onStageError = jest.fn();
mockedUseGLTF.mockImplementation(() => {
throw error;
});
render(<NotebookStage3D {...props} />);
render(<NotebookStage3D {...props} onStageError={onStageError} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /abrir projeto/i }),
).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;
onSelectTab: (index: number) => void;
isVisible: boolean;
onStageError?: () => void;
};
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
@@ -43,13 +44,13 @@ const OPEN_LID_ANGLE = 112;
function createMaterials(): MaterialMap {
return {
body: new THREE.MeshStandardMaterial({
color: "#102020",
color: "#183333",
metalness: 0.78,
roughness: 0.3,
transparent: true,
}),
frame: new THREE.MeshStandardMaterial({
color: "#294345",
color: "#3f6264",
metalness: 0.64,
roughness: 0.38,
transparent: true,
@@ -79,16 +80,17 @@ function cloneNotebookScene(scene: THREE.Object3D): ClonedNotebookScene {
meshes.push(object);
object.geometry = object.geometry.clone();
const sourceMaterial = Array.isArray(object.material)
? object.material[0]
: object.material;
const materialName = sourceMaterial?.name ?? "";
object.material =
materialName === "metal"
const mapMaterial = (sourceMaterial: THREE.Material) => {
const materialName = sourceMaterial.name;
return materialName === "metal"
? materials.screen
: materialName === "metalMedium"
? materials.frame
: materials.body;
};
object.material = Array.isArray(object.material)
? object.material.map(mapMaterial)
: mapMaterial(object.material);
object.frustumCulled = false;
});
@@ -139,7 +141,7 @@ function ScreenSurface({
return (
<Html
center
distanceFactor={4.6}
distanceFactor={0.52}
position={[-0.132, 0.087, 0.081]}
transform
zIndexRange={[20, 0]}
@@ -151,6 +153,7 @@ function ScreenSurface({
<NotebookBrowser
activeIndex={activeIndex}
frame={frame}
interactive={frame.screenOpacity > 0.05}
onSelectTab={onSelectTab}
projects={projects}
/>
@@ -214,15 +217,14 @@ function NotebookScene({
{base && lid ? (
<>
<primitive object={base} />
<group ref={lidRig}>
<primitive object={lid} />
<primitive ref={lidRig} object={lid}>
<ScreenSurface
activeIndex={activeIndex}
frame={frame}
onSelectTab={onSelectTab}
projects={projects}
/>
</group>
</primitive>
</>
) : (
<>
@@ -251,6 +253,10 @@ class NotebookStageBoundary extends Component<
return { failed: true };
}
componentDidCatch() {
this.props.onStageError?.();
}
render(): ReactNode {
if (this.state.failed) {
return (
+31 -1
View File
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { portfolio } from "@/lib/portfolio-data";
import NotebookStory, {
canUseNotebook3D,
getNotebookRunwayVh,
getNotebookSeekProgress,
} from "./NotebookStory";
@@ -88,25 +89,54 @@ describe("NotebookStory", () => {
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
});
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
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 () => {
mockMatchMedia();
render(<NotebookStory projects={[]} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(
screen.getByLabelText("Projetos em produção no notebook"),
).toBeInTheDocument();
await waitFor(() =>
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", () => {
const matchMedia = (query: string) =>
({
+44 -3
View File
@@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProject } from "@/lib/portfolio-data";
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(
index: number,
tabCount: number,
@@ -58,7 +67,28 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
const [isVisible, setIsVisible] = useState(true);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
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]);
useEffect(() => {
@@ -127,6 +157,10 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
};
}, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => {
setEngine(false);
}, []);
const seekToTab = useCallback(
(index: number) => {
const section = sectionRef.current;
@@ -161,7 +195,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
if (!engine) {
return (
<section
aria-label="Projetos em produção"
aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-static"
data-testid="notebook-story"
id="story"
@@ -183,12 +217,18 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
return (
<section
aria-label="Apresentação em scroll contínuo"
aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-runway"
data-notebook-engine="3d"
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
data-testid="notebook-story"
id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
} as CSSProperties
}
>
<div className="notebook-story-sticky">
<div className="notebook-story-grid">
@@ -208,6 +248,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
activeIndex={activeIndex}
isVisible={isVisible}
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
projects={notebookProjects}
/>
+16 -21
View File
@@ -1,6 +1,6 @@
"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.
@@ -47,10 +47,8 @@ const TO_VARS = {
};
export default function ReelDirector() {
const alive = useRef(true);
useEffect(() => {
alive.current = true;
let cancelled = false;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return undefined;
@@ -64,14 +62,14 @@ export default function ReelDirector() {
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (!alive.current) return;
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const done = new WeakSet<Element>();
const ctx = gsap.context(() => {
const scan = () => {
if (!alive.current) return;
if (cancelled) return;
for (const el of document.querySelectorAll<HTMLElement>(
"[data-reel]",
)) {
@@ -94,24 +92,21 @@ export default function ReelDirector() {
)) {
if (done.has(container)) continue;
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 children = Array.from(container.children) as HTMLElement[];
if (children.length === 0) continue;
gsap.fromTo(
children,
fromVars(dir, dist),
{
...TO_VARS,
stagger: { each: 0.09 },
scrollTrigger: {
trigger: container,
start: container.dataset.reelStart || "top 85%",
end: container.dataset.reelEnd || "top 25%",
scrub: 0.6,
},
gsap.fromTo(children, fromVars(dir, dist), {
...TO_VARS,
stagger: { each: 0.09 },
scrollTrigger: {
trigger: container,
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();
return () => {
alive.current = false;
cancelled = true;
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
window.clearTimeout(timer);
+13 -2
View File
@@ -64,8 +64,8 @@ describe("getNotebookFrame", () => {
});
it("emits a deterministic cursor click pulse for each tab", () => {
const first = getNotebookFrame(0.447, 4);
const later = getNotebookFrame(0.53, 4);
const first = getNotebookFrame(0.475, 4);
const later = getNotebookFrame(0.56, 4);
expect(first.cursorClicking).toBe(true);
expect(later.cursorClicking).toBe(true);
@@ -75,6 +75,17 @@ describe("getNotebookFrame", () => {
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", () => {
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
});
+16 -3
View File
@@ -81,6 +81,7 @@ function getCursorPoint(
progress: number,
phase: NotebookPhase,
activeTabIndex: number,
tabCount: number,
): { x: number; y: number } {
if (phase === "cursor-in") {
const cursorProgress = (progress - 0.34) / 0.1;
@@ -94,8 +95,19 @@ function getCursorPoint(
return { x: 0.06, y: 0.88 };
}
const point = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
return point;
const target = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
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(
@@ -124,7 +136,7 @@ function isCursorClicking(
const browseProgress =
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
const slotProgress = (browseProgress * tabCount) % 1;
return slotProgress < 0.12;
return slotProgress >= 0.36 && slotProgress < 0.5;
}
export function getNotebookFrame(
@@ -173,6 +185,7 @@ export function getNotebookFrame(
normalizedProgress,
phase,
activeTabIndex,
normalizedTabCount,
);
return {