refactor(home): remove a secao animada do notebook

O mergulho do hero termina direto em Projetos. As capturas de tela seguem nos
cards (ProjectCover). Remove componentes, testes e 670 linhas de CSS do notebook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Opus 5.5 committed 2026-10-05 01:12:08 -03:00
1 parent 92c602c0ad
commit 44efcfcb20
18 files changed
+30 -3059

No files matched your search

+7 -3
View File
@@ -1,10 +1,14 @@
import Image from "next/image";
import { SCREENSHOT_BY_PROJECT_ID } from "./story/notebookProjects";
type CoverProject = { id: string; title: string };
const SCREENSHOTS: Record<string, string | undefined> =
SCREENSHOT_BY_PROJECT_ID;
/** Capturas de tela publicadas dos projetos que têm interface pública. */
const SCREENSHOTS: Record<string, string | undefined> = {
nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/notebook/as-servicos.webp",
};
function initials(title: string) {
return title
+12 -12
View File
@@ -9,7 +9,7 @@ function HomeNavigation() {
return (
<>
<button type="button" onClick={onLayoutReady}>
Establish notebook layout
Establish layout
</button>
<a href="/#about">Sobre</a>
<a href="/#contact">Contato</a>
@@ -42,7 +42,7 @@ afterEach(() => {
});
describe("home fragment navigation", () => {
it("starts an unfragmented visit at the top after the notebook establishes layout", () => {
it("starts an unfragmented visit at the top after the home establishes layout", () => {
let restoredPosition = 3200;
jest
.spyOn(window, "scrollTo")
@@ -52,7 +52,7 @@ describe("home fragment navigation", () => {
});
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(restoredPosition).toBe(0);
});
@@ -63,7 +63,7 @@ describe("home fragment navigation", () => {
.mockImplementation(() => {});
const firstVisit = render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
firstVisit.unmount();
expect(window.history.scrollRestoration).not.toBe("manual");
@@ -71,7 +71,7 @@ describe("home fragment navigation", () => {
scrollTo.mockClear();
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(scrollTo).not.toHaveBeenCalled();
expect(window.history.scrollRestoration).not.toBe("manual");
@@ -88,24 +88,24 @@ describe("home fragment navigation", () => {
position = 600;
fireEvent.wheel(window, { deltaY: 600 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(position).toBe(600);
});
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
it("waits for the home layout on fragment arrival, then never steals focus on refresh", () => {
window.history.replaceState(null, "", "/#contact");
render(<HomeNavigation />);
const contact = screen.getByRole("region", { name: "Contato" });
expect(contact).not.toHaveFocus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(contact).toHaveFocus();
screen.getByRole("button", { name: "Read biography" }).focus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(
screen.getByRole("button", { name: "Read biography" }),
@@ -119,7 +119,7 @@ describe("home fragment navigation", () => {
message.focus();
fireEvent.wheel(window, { deltaY: 200 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(message).toHaveFocus();
});
@@ -128,7 +128,7 @@ describe("home fragment navigation", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
const link = screen.getByRole("link", { name: "Sobre" });
link.focus();
@@ -152,7 +152,7 @@ describe("home fragment navigation", () => {
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
screen.getByRole("button", { name: "Establish layout" }),
);
expect(message).toHaveFocus();
});
+3 -4
View File
@@ -14,9 +14,8 @@ import Hero, { type HeroStats } from "../Hero";
import LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects";
import Skills from "../Skills";
import NotebookStory from "../story/NotebookStory";
import { WorkModalities } from "../WorkModalities";
import { useRef } from "react";
import { useEffect, useRef } from "react";
import ExperienceDisclosure from "./ExperienceDisclosure";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
@@ -35,6 +34,8 @@ export default function CinematicHome({
const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef);
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
useEffect(() => onLayoutReady(), [onLayoutReady]);
return (
<div className="flex min-h-screen flex-col overflow-x-clip">
@@ -42,8 +43,6 @@ export default function CinematicHome({
<main ref={mainRef} className="ds-main relative z-0">
<Hero stats={heroStats} />
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
<Projects projects={projects} />
<WorkModalities />
+1 -1
View File
@@ -38,7 +38,7 @@ export default function HeroField() {
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU para o notebook
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
+7 -22
View File
@@ -36,35 +36,20 @@ const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
// a seção que entra também fica parada no topo: a transição acontece "no lugar"
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
const stageOf = (section: HTMLElement) =>
section.querySelector<HTMLElement>(".notebook-story-sticky") ?? section;
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → notebook: o mergulho termina no escuro, o hero some e o notebook abre como tela de cinema
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
const band = (open: number) => () =>
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo(
stageOf(next),
{ opacity: 0, clipPath: "inset(32% 0% 32% 0%)" },
{ opacity: 1, clipPath: "inset(0% 0% 0% 0%)", duration: 0.65 },
0.35,
);
},
// notebook → projetos: a seção sobe como um cartão e o notebook recolhe ao fundo
(tl, prev, next) => {
holdPrev(tl, prev);
tl.fromTo(
stageOf(prev),
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" },
{ opacity: 0.15, clipPath: "inset(8% 6% 8% 6% round 32px)" },
0,
).fromTo(
next,
{ clipPath: "inset(0% 5% 0% 5% round 40px 40px 0px 0px)" },
{ clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
0,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
@@ -197,7 +182,7 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
const scenes = Array.from(main.children) as HTMLElement[];
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima do notebook
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 });
});
TRANSITIONS.forEach((transition, index) => {
@@ -1,285 +0,0 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(getFeaturedProjects(null));
const frame: NotebookBrowserProps["frame"] = {
cursorVisible: true,
cursorX: 0.4,
cursorY: 0.6,
cursorClicking: false,
};
function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
return render(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
{...overrides}
/>,
);
}
function imageFor(container: HTMLElement, index: number) {
const source = new URL(projects[index].screenshotSrc, document.baseURI).href;
const image = Array.from(container.querySelectorAll("img")).find(
(candidate) => candidate.src === source,
);
if (!image) throw new Error(`Missing screenshot for ${projects[index].id}`);
return image;
}
describe("NotebookBrowser", () => {
it("renders the active project as an accessible tabpanel", () => {
renderBrowser({ activeIndex: 1 });
expect(
screen.getByRole("tablist", { name: /projetos em produção/i }),
).toBeInTheDocument();
expect(screen.getAllByRole("tab")[1]).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SC Plus Aquicultura/i,
);
expect(screen.getByRole("status")).toHaveTextContent(
"Projeto ativo: SC Plus Aquicultura",
);
});
it("keeps decoded content visible and ignores a superseded decode", async () => {
const view = renderBrowser();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
let finishDecode!: () => void;
imageFor(view.container, 1).decode = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishDecode = resolve;
}),
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={2}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 2));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
await act(async () => {
finishDecode();
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
});
it("keeps the last capture with a truthful notice when the requested image fails", async () => {
const view = renderBrowser();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
fireEvent.error(imageFor(view.container, 1));
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
expect(
screen.getByText(/não foi possível carregar.*SC Plus/i),
).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/SC Plus/i);
});
it("can reverse to an already-loaded outgoing image without another load event", async () => {
const view = renderBrowser();
const first = imageFor(view.container, 0);
Object.defineProperties(first, {
complete: { value: true },
naturalWidth: { value: 1280 },
});
await act(async () => {
fireEvent.load(first);
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
expect(
await screen.findByRole("img", { name: /NutriScan/i }),
).toBeInTheDocument();
});
it("moves between tabs with arrow keys and Home/End", async () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[1]);
await user.keyboard("{ArrowRight}");
await user.keyboard("{Home}");
await user.keyboard("{End}");
expect(onSelectTab).toHaveBeenNthCalledWith(1, 1);
expect(onSelectTab).toHaveBeenNthCalledWith(2, 2);
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();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[0]);
await user.keyboard("{ArrowLeft}");
await user.click(tabs.at(-1) as HTMLElement);
await user.keyboard("{ArrowRight}");
expect(onSelectTab).toHaveBeenNthCalledWith(2, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("keeps an unknown demo link and announces its state", () => {
const unknownProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unknown" as const,
demoCheckedAt: null,
}
: project,
);
renderBrowser({ projects: unknownProjects });
const link = screen.getByRole("link", {
name: /abrir projeto.*disponibilidade não verificada/i,
});
expect(link).toHaveAttribute("href", projects[0].demoUrl);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("renders an unavailable demo as non-navigable text", () => {
const unavailableProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
renderBrowser({ projects: unavailableProjects });
const unavailable = screen.getByText(
"Demo indisponível na última verificação",
);
expect(unavailable).toHaveAttribute("aria-disabled", "true");
expect(unavailable.closest("a")).toBeNull();
expect(
screen.queryByRole("link", { name: /abrir projeto/i }),
).not.toBeInTheDocument();
});
it("renders no demo action for a project without a demo", () => {
const noDemoProjects = [
{
...projects[0],
demoUrl: null,
demoAvailability: "none",
demoCheckedAt: null,
},
] as unknown as NotebookBrowserProps["projects"];
renderBrowser({ projects: noDemoProjects });
expect(screen.queryByText(/abrir projeto/i)).not.toBeInTheDocument();
expect(
screen.queryByText("Demo indisponível na última verificação"),
).not.toBeInTheDocument();
});
it("uses the selected tab to label the panel and decoded screenshot", async () => {
const view = renderBrowser({ activeIndex: 2 });
await act(async () => {
fireEvent.load(imageFor(view.container, 2));
});
const tab = screen.getAllByRole("tab")[2];
const panel = screen.getByRole("tabpanel");
const image = screen.getByRole("img");
expect(tab).toHaveAttribute("aria-controls", panel.id);
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
expect(image).toHaveProperty(
"src",
new URL(projects[2].screenshotSrc, document.baseURI).href,
);
});
});
-427
View File
@@ -1,427 +0,0 @@
"use client";
import Image from "next/image";
import {
type Ref,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { NotebookProject } from "./notebookProjects";
import type { NotebookFrame } from "./notebookTimeline";
type CursorFrame = Pick<
NotebookFrame,
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
export type NotebookBrowserHandle = {
updateFrame: (frame: CursorFrame) => void;
};
export type NotebookBrowserProps = {
projects: NotebookProject[];
activeIndex: number;
frame: CursorFrame;
ref?: Ref<NotebookBrowserHandle>;
onSelectTab: (index: number) => void;
interactive?: boolean;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
function toDomId(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
export default function NotebookBrowser({
projects,
activeIndex,
frame,
onSelectTab,
interactive = true,
ref,
}: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject =
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
const rootRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<SVGSVGElement>(null);
const cursorFrame = useRef(frame);
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
const browserSize = useRef({ width: 0, height: 0 });
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const requestedSrc = selectedProject?.screenshotSrc;
const requestRef = useRef(requestedSrc);
const requestVersion = useRef(0);
const displayedRef = useRef(displayed);
const mounted = useRef(true);
useLayoutEffect(() => {
requestRef.current = requestedSrc;
requestVersion.current += 1;
setFailedSrc(null);
}, [requestedSrc]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
// Only the immediate neighbours are fetched; the browser cache handles reuse.
useEffect(() => {
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
.filter((project): project is NotebookProject => Boolean(project))
.map((project) => {
const image = new window.Image();
image.decoding = "async";
image.src = project.screenshotSrc;
return image;
});
return () => {
images.forEach((image) => {
image.removeAttribute("src");
});
};
}, [projects, selectedIndex]);
const updateFrame = useCallback((next: CursorFrame) => {
cursorFrame.current = next;
const cursor = cursorRef.current;
const points = tabPoints.current;
if (!cursor || points.length === 0) return;
const position = Math.max(
0,
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
);
const left = Math.floor(position);
const right = Math.min(left + 1, points.length - 1);
const blend = position - left;
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
cursor.style.opacity = next.cursorVisible ? "1" : "0";
cursor.classList.toggle("is-clicking", next.cursorClicking);
}, []);
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
const measure = () => {
browserSize.current = {
width: root.clientWidth,
height: root.clientHeight,
};
const points = [];
for (const project of projects) {
const tab: HTMLButtonElement | null =
root.querySelector<HTMLButtonElement>(
`#notebook-tab-${toDomId(project.id)}`,
);
if (!tab) continue;
let x = tab.offsetWidth / 2;
let y = tab.offsetHeight / 2;
let element: HTMLElement | null = tab;
while (element && element !== root) {
x += element.offsetLeft;
y += element.offsetTop;
element = element.offsetParent as HTMLElement | null;
}
points.push({ x, y });
}
tabPoints.current = points;
updateFrame(cursorFrame.current);
};
measure();
if (!("ResizeObserver" in window)) return undefined;
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [projects, updateFrame]);
useLayoutEffect(() => {
updateFrame(frame);
}, [frame, updateFrame]);
const revealImage = useCallback(
async (image: HTMLImageElement, project: NotebookProject) => {
const version = requestVersion.current;
try {
if (typeof image.decode === "function") await image.decode();
} catch {
if (
mounted.current &&
version === requestVersion.current &&
requestRef.current === project.screenshotSrc
) {
setFailedSrc(project.screenshotSrc);
}
return;
}
if (
!mounted.current ||
version !== requestVersion.current ||
requestRef.current !== project.screenshotSrc
)
return;
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
setOutgoing(
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
? null
: displayedRef.current,
);
displayedRef.current = project;
setDisplayed(project);
setFailedSrc(null);
},
[],
);
useLayoutEffect(() => {
if (!selectedProject) return;
// A quick reversal can select the still-mounted outgoing image: no new load event.
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
.href;
const image = Array.from(
rootRef.current?.querySelectorAll("img") ?? [],
).find((item) => item.src === source);
if (image?.complete && image.naturalWidth > 0) {
void revealImage(image, selectedProject);
}
}, [selectedProject, revealImage]);
const imageProjects = [outgoing, displayed, selectedProject].filter(
(project, index, list): project is NotebookProject =>
Boolean(project) &&
list.findIndex(
(item) => item?.screenshotSrc === project?.screenshotSrc,
) === index,
);
const waiting = displayed?.screenshotSrc !== requestedSrc;
const interactionProps = interactive
? {}
: {
"aria-hidden": true,
inert: true,
};
function handleTabKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number,
) {
if (projects.length < 1) return;
let nextIndex: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
nextIndex = Math.min(index + 1, projects.length - 1);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
nextIndex = Math.max(index - 1, 0);
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = projects.length - 1;
}
if (nextIndex === null) return;
event.preventDefault();
event.currentTarget.parentElement
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus({ preventScroll: true });
onSelectTab(nextIndex);
}
if (!selectedProject) {
return (
<div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
data-empty
>
<p aria-live="polite">Nenhum projeto disponível para esta cena.</p>
</div>
);
}
const selectedId = toDomId(selectedProject.id);
const panelId = `notebook-panel-${selectedId}`;
return (
<div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
ref={rootRef}
>
<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-address">
evandro.dev.br / projetos
</span>
</div>
<div className="notebook-browser-body">
<div className="notebook-browser-heading">
<span className="notebook-browser-eyebrow">Projetos em produção</span>
<span className="notebook-browser-counter">
{String(selectedIndex + 1).padStart(2, "0")} /{" "}
{String(projects.length).padStart(2, "0")}
</span>
</div>
<output className="sr-only" aria-live="polite">
Projeto ativo: {selectedProject.title}
</output>
<div
className="notebook-tablist"
role="tablist"
aria-label="Projetos em produção"
>
{projects.map((project, index) => {
const tabId = `notebook-tab-${toDomId(project.id)}`;
const isSelected = index === selectedIndex;
return (
<button
aria-controls={panelId}
aria-selected={isSelected}
className="notebook-tab"
id={tabId}
key={project.id}
onClick={() => onSelectTab(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
role="tab"
tabIndex={isSelected ? 0 : -1}
type="button"
>
<span className="notebook-tab-number">
{String(index + 1).padStart(2, "0")}
</span>
<span className="notebook-tab-title">{project.title}</span>
</button>
);
})}
</div>
<div
aria-labelledby={`notebook-tab-${selectedId}`}
className="notebook-panel"
id={panelId}
role="tabpanel"
>
<div className="notebook-panel-copy">
<h3>{selectedProject.title}</h3>
<p>{selectedProject.subtitle}</p>
<ul aria-label={`Tecnologias de ${selectedProject.title}`}>
{selectedProject.technologies.slice(0, 4).map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
{selectedProject.demoAvailability === "unavailable" ? (
<span aria-disabled="true">
Demo indisponível na última verificação
</span>
) : selectedProject.demoAvailability === "none" ||
!selectedProject.demoUrl ? null : (
<a
href={selectedProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto <span aria-hidden="true">↗</span>
{selectedProject.demoAvailability === "unknown" ? (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
) : null}
</a>
)}
</div>
<div
className="notebook-screenshot-wrap"
aria-busy={waiting && failedSrc !== requestedSrc}
>
{imageProjects.map((project) => {
const current =
project.screenshotSrc === displayed?.screenshotSrc;
const previous =
project.screenshotSrc === outgoing?.screenshotSrc;
return (
<Image
key={project.screenshotSrc}
alt={current ? `Captura de tela de ${project.title}` : ""}
aria-hidden={!current}
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
width={project.screenshotWidth}
height={project.screenshotHeight}
loading="eager"
sizes="(min-width: 1025px) 800px, 100vw"
src={project.screenshotSrc}
unoptimized
onLoad={(event) => {
void revealImage(event.currentTarget, project);
}}
onError={() => {
if (project.screenshotSrc === requestRef.current)
setFailedSrc(project.screenshotSrc);
}}
onAnimationEnd={() => {
if (current) setOutgoing(null);
}}
/>
);
})}
{waiting ? (
<p className="notebook-screenshot-status" aria-live="polite">
{failedSrc === requestedSrc
? `Não foi possível carregar a captura de ${selectedProject.title}.`
: `Carregando captura de ${selectedProject.title}…`}
{displayed
? ` Exibindo ${displayed.title} enquanto isso.`
: null}
</p>
) : null}
</div>
</div>
</div>
<svg
aria-hidden="true"
className="notebook-cursor"
ref={cursorRef}
viewBox="0 0 28 36"
width="28"
height="36"
>
<title>Cursor decorativo</title>
<path
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
fill="#0a0a0a"
stroke="#f4f2ed"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<circle
className="notebook-cursor-pulse"
cx="2"
cy="2"
r="8"
fill="none"
stroke="#ccff00"
strokeWidth="2"
/>
</svg>
</div>
);
}
@@ -1,43 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(getFeaturedProjects(null));
describe("NotebookFallback", () => {
it("keeps project content and controls in normal document flow", () => {
const { container } = render(<NotebookFallback projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /abrir projeto/i }),
).toBeInTheDocument();
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("allows manual tab changes without an animated stage", async () => {
const user = userEvent.setup();
const { container } = render(<NotebookFallback projects={projects} />);
await user.click(screen.getAllByRole("tab")[2]);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SEV Moda Íntima/i,
);
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("respects the requested initial project", () => {
render(<NotebookFallback initialIndex={3} projects={projects} />);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/AS Serviços/i);
expect(screen.getAllByRole("tab")[3]).toHaveAttribute(
"aria-selected",
"true",
);
});
});
-52
View File
@@ -1,52 +0,0 @@
"use client";
import { useState } from "react";
import NotebookBrowser from "./NotebookBrowser";
import type { NotebookProject } from "./notebookProjects";
export type NotebookFallbackProps = {
projects: NotebookProject[];
initialIndex?: number;
onSelectTab?: (index: number) => void;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
export default function NotebookFallback({
projects,
initialIndex = 0,
onSelectTab,
}: NotebookFallbackProps) {
const [activeIndex, setActiveIndex] = useState(() =>
clampIndex(initialIndex, projects.length),
);
return (
<div className="notebook-fallback" data-testid="notebook-fallback">
<div className="notebook-fallback-intro">
<span className="notebook-fallback-kicker">Interface em produção</span>
<p>
Navegue pelos projetos com as abas abaixo. A experiência permanece
completa sem movimento ou WebGL.
</p>
</div>
<NotebookBrowser
activeIndex={activeIndex}
frame={{
cursorClicking: false,
cursorVisible: false,
cursorX: 0,
cursorY: 0,
}}
onSelectTab={(index) => {
setActiveIndex(index);
onSelectTab?.(index);
}}
projects={projects}
/>
</div>
);
}
@@ -1,131 +0,0 @@
import { render, screen } from "@testing-library/react";
import * as THREE from "three";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStage3D, {
getNotebookLidRotation,
type NotebookStage3DProps,
updateNotebookScreenMatrix,
} from "./NotebookStage3D";
import { selectNotebookProjects } from "./notebookProjects";
type MockCanvasProps = {
children: React.ReactNode;
frameloop?: string;
};
const mockCanvas = jest.fn(({ children, frameloop }: MockCanvasProps) => (
<div data-frameloop={frameloop} data-testid="mock-canvas">
{children}
</div>
));
jest.mock("@react-three/fiber", () => ({
Canvas: (props: MockCanvasProps) => mockCanvas(props),
useFrame: jest.fn(),
}));
jest.mock("@react-three/drei", () => ({
Html: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-html">{children}</div>
),
RoundedBox: () => <div data-testid="mock-roundedbox" />,
}));
const projects = selectNotebookProjects(getFeaturedProjects(null));
const props: NotebookStage3DProps = {
activeIndex: 0,
isVisible: true,
onSelectTab: jest.fn(),
progress: { current: 0.56 },
requestRender: { current: null },
projects,
};
describe("getNotebookLidRotation", () => {
it("keeps the lid closed at rest and opens around the hinge", () => {
expect(getNotebookLidRotation(0)).toBe(0);
expect(getNotebookLidRotation(112)).toBeCloseTo(
-THREE.MathUtils.degToRad(112),
6,
);
expect(getNotebookLidRotation(56)).toBeCloseTo(
-THREE.MathUtils.degToRad(56),
6,
);
});
it("clamps angles outside the physical hinge range", () => {
expect(getNotebookLidRotation(-10)).toBe(0);
expect(getNotebookLidRotation(200)).toBe(getNotebookLidRotation(180));
});
});
it("keeps browser corners on the physical LCD after opening and resizing", () => {
const matrix = new THREE.Matrix4();
const rig = new THREE.Group();
rig.position.set(0, -0.45, 0);
const hinge = new THREE.Group();
hinge.position.set(0, 0.16, -1.06);
rig.add(hinge);
const lcd = new THREE.Object3D();
lcd.position.set(0, -0.028, 1.1);
lcd.rotation.x = Math.PI / 2;
hinge.add(lcd);
const domWidth = 800;
const domHeight = (domWidth * 1.86) / 2.8;
for (const size of [
{ width: 939.390625, height: 738, lidAngle: 74 },
{ width: 1123.375, height: 629.75, lidAngle: 112 },
]) {
const camera = new THREE.PerspectiveCamera(30, size.width / size.height);
camera.position.set(0, 1.65, 5.7);
camera.rotation.x = -0.19;
hinge.rotation.x = getNotebookLidRotation(size.lidAngle);
updateNotebookScreenMatrix(matrix, lcd, camera, size);
for (const [u, v] of [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
]) {
const expected = new THREE.Vector3((u - 0.5) * 2.8, (0.5 - v) * 1.86, 0)
.applyMatrix4(lcd.matrixWorld)
.project(camera);
const actual = new THREE.Vector3(
u * domWidth,
v * domHeight,
0,
).applyMatrix4(matrix);
expect(actual.x).toBeCloseTo(((expected.x + 1) * size.width) / 2, 6);
expect(actual.y).toBeCloseTo(((1 - expected.y) * size.height) / 2, 6);
}
}
});
describe("NotebookStage3D", () => {
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("returns usable browser content when the stage boundary fails", () => {
const onStageError = jest.fn();
mockCanvas.mockImplementation(() => {
throw new Error("canvas failed");
});
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);
});
});
-468
View File
@@ -1,468 +0,0 @@
"use client";
import { Html, RoundedBox } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import * as THREE from "three";
import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
import NotebookFallback from "./NotebookFallback";
import type { NotebookProject } from "./notebookProjects";
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
type ProgressRef = { current: number };
export type NotebookStage3DProps = {
progress: ProgressRef;
requestRender: { current: (() => void) | null };
projects: NotebookProject[];
activeIndex: number;
onSelectTab: (index: number) => void;
isVisible: boolean;
onStageError?: () => void;
};
type NotebookSceneProps = NotebookStage3DProps;
/**
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
* resting on the deck), 112° = open (screen facing the camera, reclined
* ~22° past vertical). The lid pivots around the hinge axis — never around
* the display center.
*/
export function getNotebookLidRotation(lidAngle: number): number {
const angle = THREE.MathUtils.clamp(lidAngle, 0, 180);
const radians = THREE.MathUtils.degToRad(angle);
return radians === 0 ? 0 : -radians;
}
// The laptop and its DOM screen share the same world-space geometry.
const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
const CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
const HINGE: [number, number, number] = [0, 0.16, -1.06];
const LID = { width: 3.2, thickness: 0.1, depth: 2.2 };
const SCREEN_INSET = { width: 2.9, depth: 1.96 };
const SCREEN_GLASS = { width: 2.8, depth: 1.86 };
const KEY_COLUMNS = 12;
const KEY_ROWS = 5;
const KEY_SIZE: [number, number, number] = [0.185, 0.05, 0.155];
const KEY_GAP_X = 0.212;
const KEY_GAP_Z = 0.2;
const KEYBOARD_Z_START = -0.9;
const KEYBOARD_Y = 0.185;
const SPACEBAR_Z = 0.12;
const SCREEN_DOM_WIDTH = 800;
const SCREEN_DOM_HEIGHT =
(SCREEN_DOM_WIDTH * SCREEN_GLASS.depth) / SCREEN_GLASS.width;
const SCREEN_PIXELS_TO_WORLD = new THREE.Matrix4()
.makeScale(
SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
-SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
1,
)
.setPosition(-SCREEN_GLASS.width / 2, SCREEN_GLASS.depth / 2, 0);
const SCREEN_ORIGIN: [number, number] = [0, 0];
const getScreenOrigin = () => SCREEN_ORIGIN;
export function updateNotebookScreenMatrix(
matrix: THREE.Matrix4,
screen: THREE.Object3D,
camera: THREE.Camera,
size: { width: number; height: number },
): THREE.Matrix4 {
screen.updateWorldMatrix(true, false);
camera.updateMatrixWorld();
const halfWidth = size.width / 2;
const halfHeight = size.height / 2;
// One pixel-space matrix keeps CSS paint and hit-testing on the LCD plane.
matrix
.set(
halfWidth,
0,
0,
halfWidth,
0,
-halfHeight,
0,
halfHeight,
0,
0,
1,
0,
0,
0,
0,
1,
)
.multiply(camera.projectionMatrix)
.multiply(camera.matrixWorldInverse)
.multiply(screen.matrixWorld)
.multiply(SCREEN_PIXELS_TO_WORLD);
return matrix.multiplyScalar(1 / matrix.elements[15]);
}
type LaptopMaterials = {
chassis: THREE.MeshStandardMaterial;
keys: THREE.MeshStandardMaterial;
dark: THREE.MeshStandardMaterial;
accent: THREE.MeshStandardMaterial;
screen: THREE.MeshStandardMaterial;
};
function createLaptopMaterials(): LaptopMaterials {
return {
chassis: new THREE.MeshStandardMaterial({
color: "#3f545b",
metalness: 0.35,
roughness: 0.32,
transparent: true,
}),
keys: new THREE.MeshStandardMaterial({
color: "#5a747c",
metalness: 0.2,
roughness: 0.5,
transparent: true,
}),
dark: new THREE.MeshStandardMaterial({
color: "#131f24",
metalness: 0.15,
roughness: 0.55,
transparent: true,
}),
accent: new THREE.MeshStandardMaterial({
color: "#465f66",
metalness: 0.3,
roughness: 0.4,
transparent: true,
}),
screen: new THREE.MeshStandardMaterial({
color: "#0d1417",
emissive: "#22343a",
emissiveIntensity: 0.55,
metalness: 0.1,
roughness: 0.4,
transparent: true,
}),
};
}
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
materials.chassis.opacity = opacity;
materials.keys.opacity = opacity;
materials.dark.opacity = opacity;
materials.accent.opacity = opacity;
materials.screen.opacity = opacity;
}
function disposeLaptopMaterials(materials: LaptopMaterials) {
for (const material of Object.values(materials)) {
material.dispose();
}
}
function LaptopKeys({ material }: { material: THREE.MeshStandardMaterial }) {
const ref = useRef<THREE.InstancedMesh>(null);
const positions = useMemo(() => {
const list: Array<[number, number]> = [];
const startX = (-(KEY_COLUMNS - 1) * KEY_GAP_X) / 2;
for (let row = 0; row < KEY_ROWS; row += 1) {
for (let column = 0; column < KEY_COLUMNS; column += 1) {
list.push([
startX + column * KEY_GAP_X,
KEYBOARD_Z_START + row * KEY_GAP_Z,
]);
}
}
return list;
}, []);
useLayoutEffect(() => {
const mesh = ref.current;
if (!(mesh instanceof THREE.InstancedMesh)) return;
const matrix = new THREE.Matrix4();
positions.forEach(([x, z], index) => {
matrix.makeTranslation(x, KEYBOARD_Y, z);
mesh.setMatrixAt(index, matrix);
});
mesh.instanceMatrix.needsUpdate = true;
}, [positions]);
return (
<instancedMesh
args={[undefined, undefined, positions.length]}
material={material}
ref={ref}
>
<boxGeometry args={KEY_SIZE} />
</instancedMesh>
);
}
function LaptopBase({ materials }: { materials: LaptopMaterials }) {
return (
<group>
<RoundedBox
args={[BASE.width, BASE.height, BASE.depth]}
material={materials.chassis}
position={[0, BASE.height / 2, 0]}
radius={0.05}
smoothness={3}
/>
<RoundedBox
args={[2.66, 0.05, 1.3]}
material={materials.dark}
position={[0, 0.155, -0.38]}
radius={0.02}
smoothness={2}
/>
<LaptopKeys material={materials.keys} />
<mesh material={materials.keys} position={[0, KEYBOARD_Y, SPACEBAR_Z]}>
<boxGeometry args={[1.05, KEY_SIZE[1], KEY_SIZE[2]]} />
</mesh>
<RoundedBox
args={[1.15, 0.02, 0.6]}
material={materials.accent}
position={[0, 0.17, 0.68]}
radius={0.01}
smoothness={2}
/>
<mesh
material={materials.dark}
position={[0, 0.16, -1.06]}
rotation={[0, 0, Math.PI / 2]}
>
<cylinderGeometry args={[0.05, 0.05, 2.9, 16]} />
</mesh>
</group>
);
}
function LaptopLid({ materials }: { materials: LaptopMaterials }) {
return (
<group>
<RoundedBox
args={[LID.width, LID.thickness, LID.depth]}
material={materials.chassis}
position={[0, LID.thickness / 2, LID.depth / 2]}
radius={0.05}
smoothness={3}
/>
<RoundedBox
args={[SCREEN_INSET.width, 0.05, SCREEN_INSET.depth]}
material={materials.dark}
position={[0, 0, LID.depth / 2]}
radius={0.02}
smoothness={2}
/>
</group>
);
}
function ScreenSurface({
frame,
material,
projects,
activeIndex,
onSelectTab,
}: {
frame: RefObject<NotebookFrame>;
material: THREE.MeshStandardMaterial;
projects: NotebookProject[];
activeIndex: number;
onSelectTab: (index: number) => void;
}) {
const screen = useRef<THREE.Group>(null);
const surface = useRef<HTMLDivElement>(null);
const browser = useRef<NotebookBrowserHandle>(null);
const projection = useMemo(() => new THREE.Matrix4(), []);
useFrame(({ camera, size }) => {
if (surface.current && screen.current) {
updateNotebookScreenMatrix(projection, screen.current, camera, size);
surface.current.style.transform = `matrix3d(${projection.elements.join(",")})`;
surface.current.style.opacity = String(frame.current.screenOpacity);
surface.current.inert = frame.current.screenOpacity < 0.95;
surface.current.setAttribute(
"aria-hidden",
String(frame.current.screenOpacity < 0.95),
);
}
browser.current?.updateFrame(frame.current);
});
return (
<group
ref={screen}
position={[0, -0.028, LID.depth / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<mesh material={material}>
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
</mesh>
<Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
<div
ref={surface}
className="notebook-screen-surface"
style={{
width: SCREEN_DOM_WIDTH,
height: SCREEN_DOM_HEIGHT,
opacity: frame.current.screenOpacity,
}}
inert={frame.current.screenOpacity < 0.95}
aria-hidden={frame.current.screenOpacity < 0.95}
>
<NotebookBrowser
ref={browser}
activeIndex={activeIndex}
frame={frame.current}
onSelectTab={onSelectTab}
projects={projects}
/>
</div>
</Html>
</group>
);
}
function NotebookScene({
activeIndex,
onSelectTab,
progress,
projects,
requestRender,
isVisible,
onStageError,
}: NotebookSceneProps) {
const lidRig = useRef<THREE.Group>(null);
const frame = useRef(getNotebookFrame(progress.current, projects.length));
const materials = useMemo(createLaptopMaterials, []);
const { camera, gl, size, invalidate } = useThree();
useLayoutEffect(() => {
if (!(camera instanceof THREE.PerspectiveCamera)) return;
// Preserve desktop framing; widen portrait views without stretching the LCD.
camera.zoom =
window.innerWidth <= 1024
? Math.min(1, size.width / Math.max(size.height, 1) / 1.5)
: 1;
camera.updateProjectionMatrix();
invalidate();
}, [camera, invalidate, size.width, size.height]);
useEffect(() => {
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
onStageError?.();
};
canvas.addEventListener("webglcontextlost", handleContextLost);
return () =>
canvas.removeEventListener("webglcontextlost", handleContextLost);
}, [gl, onStageError]);
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
useEffect(() => {
requestRender.current = isVisible ? invalidate : null;
if (isVisible) invalidate();
return () => {
requestRender.current = null;
};
}, [invalidate, isVisible, requestRender]);
useFrame(() => {
const nextFrame = getNotebookFrame(
progress.current,
projects.length,
frame.current,
);
if (lidRig.current) {
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
}
setLaptopOpacity(materials, nextFrame.deviceOpacity);
}, -1);
return (
<group position={RIG_POSITION}>
<ambientLight intensity={1.35} />
<hemisphereLight args={["#f4f2ed", "#0a0a0a", 1.15]} />
<directionalLight color="#f4f2ed" intensity={2.6} position={[3, 6, 6]} />
<directionalLight color="#ccff00" intensity={0.7} position={[-5, 2, 3]} />
<LaptopBase materials={materials} />
<group position={HINGE} ref={lidRig}>
<LaptopLid materials={materials} />
<ScreenSurface
activeIndex={activeIndex}
frame={frame}
material={materials.screen}
onSelectTab={onSelectTab}
projects={projects}
/>
</group>
</group>
);
}
class NotebookStageBoundary extends Component<
NotebookStage3DProps,
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onStageError?.();
}
render(): ReactNode {
if (this.state.failed) {
return (
<NotebookFallback
initialIndex={this.props.activeIndex}
projects={this.props.projects}
/>
);
}
return (
<Canvas
camera={{
fov: 30,
position: CAMERA_POSITION,
rotation: CAMERA_ROTATION,
}}
className="notebook-story-canvas"
dpr={[1, 1.5]}
frameloop={this.props.isVisible ? "demand" : "never"}
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
}}
>
<NotebookScene {...this.props} />
</Canvas>
);
}
}
export default function NotebookStage3D(props: NotebookStage3DProps) {
return (
<div className="notebook-story-stage" data-testid="notebook-stage">
<NotebookStageBoundary {...props} />
</div>
);
}
-265
View File
@@ -1,265 +0,0 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
));
jest.mock("next/dynamic", () => () => {
return function DynamicNotebookStage(props: { isVisible: boolean }) {
return mockNotebookStage(props);
};
});
jest.mock("gsap", () => ({
gsap: {
registerPlugin: jest.fn(),
timeline: jest.fn(() => ({ to: jest.fn().mockReturnThis() })),
context: jest.fn((callback: () => void) => {
callback();
return { revert: jest.fn() };
}),
},
}));
jest.mock("gsap/ScrollTrigger", () => ({
ScrollTrigger: { getAll: jest.fn(() => []) },
}));
const projects = getFeaturedProjects(null);
let intersectionCallback: IntersectionObserverCallback | undefined;
let reducedMotionListener: (() => void) | undefined;
const mockObserve = jest.fn();
const mockDisconnect = jest.fn();
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setSectionIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function mockMatchMedia({
reduced = false,
coarse = false,
hover = true,
}: {
reduced?: boolean;
coarse?: boolean;
hover?: boolean;
} = {}) {
const matcher = jest.isMockFunction(window.matchMedia)
? window.matchMedia
: jest.spyOn(window, "matchMedia");
matcher.mockImplementation((query: string) => ({
matches:
(query === "(prefers-reduced-motion: reduce)" && reduced) ||
(query === "(pointer: coarse)" && coarse) ||
(query === "(hover: hover)" && hover),
media: query,
onchange: null,
addEventListener: jest.fn((event: string, callback: () => void) => {
if (query === "(prefers-reduced-motion: reduce)" && event === "change") {
reducedMotionListener = callback;
}
}),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
}
describe("NotebookStory", () => {
beforeEach(() => {
mockMatchMedia();
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1280,
});
setDocumentHidden(false);
intersectionCallback = undefined;
reducedMotionListener = undefined;
mockObserve.mockReset();
mockDisconnect.mockReset();
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = mockDisconnect;
observe = mockObserve;
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders the static fallback when reduced motion is enabled", async () => {
mockMatchMedia({ reduced: true });
render(<NotebookStory projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
});
it("initializes the 3D stage on a narrow touch viewport without hover", async () => {
mockMatchMedia({ coarse: true, hover: false });
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 390,
});
render(<NotebookStory projects={projects} />);
expect(await screen.findByTestId("notebook-stage")).toBeInTheDocument();
expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument();
});
it("lets touch users select and open a project outside the scaled screen", async () => {
mockMatchMedia({ coarse: true, hover: false });
render(<NotebookStory projects={projects} />);
await screen.findByTestId("notebook-stage");
const controls = screen.getByRole("navigation", {
name: "Navegar projetos do notebook",
});
const button = within(controls).getByRole("button", {
name: /Mostrar SC/i,
});
fireEvent.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
expect(
within(controls).getByRole("link", { name: /Abrir projeto/i }),
).toHaveAttribute(
"href",
projects.find((project) => project.id === "sc-plus")?.demoUrl,
);
});
it("returns to static content if reduced motion is enabled during the story", async () => {
mockMatchMedia({ coarse: true, hover: false });
render(<NotebookStory projects={projects} />);
await screen.findByTestId("notebook-stage");
mockMatchMedia({ reduced: true, coarse: true, hover: false });
act(() => reducedMotionListener?.());
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument();
});
it("initializes the 3D stage on a desktop viewport", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
);
});
it("stays paused when the tab returns while the section is offscreen", async () => {
render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage");
expect(stage).toHaveAttribute("data-is-visible", "false");
setSectionIntersects(true);
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "true"),
);
setDocumentHidden(true);
act(() => document.dispatchEvent(new Event("visibilitychange")));
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "false"),
);
setSectionIntersects(false);
setDocumentHidden(false);
act(() => document.dispatchEvent(new Event("visibilitychange")));
expect(stage).toHaveAttribute("data-is-visible", "false");
});
it("keeps the mounted 3D stage when resizing from desktop to touch mobile", async () => {
render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 360,
});
mockMatchMedia({ coarse: true, hover: false });
act(() => window.dispatchEvent(new Event("resize")));
expect(screen.getByTestId("notebook-stage")).toBe(stage);
expect(screen.queryByTestId("notebook-fallback")).not.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("respects reduced motion independently of width and pointer capabilities", () => {
const matchMedia = (query: string) =>
({
matches: query === "(hover: hover)",
}) as MediaQueryList;
expect(canUseNotebook3D({ matchMedia })).toBe(true);
expect(
canUseNotebook3D({
matchMedia: (query) =>
({
matches:
query === "(hover: hover)" ||
query === "(prefers-reduced-motion: reduce)",
}) as MediaQueryList,
}),
).toBe(false);
});
it("passes only mapped projects into the fallback browser", () => {
mockMatchMedia({ reduced: true });
render(<NotebookStory projects={projects} />);
expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: /Twins Lab/i }),
).not.toBeInTheDocument();
});
});
-333
View File
@@ -1,333 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
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, getNotebookSeekProgress } from "./notebookTimeline";
import { pageTop } from "../cinematic/useCinematicScroll";
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
ssr: false,
});
type CapabilityWindow = Pick<Window, "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProjectView[];
onLayoutReady?: () => void;
};
export function canUseNotebook3D(view: CapabilityWindow): boolean {
if (typeof view.matchMedia !== "function") return false;
return !view.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function getNotebookRunwayVh(tabCount: number): number {
const count = Math.max(
1,
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
);
return 220 + count * 85;
}
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
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);
const [isPageVisible, setIsPageVisible] = useState(
() => 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 =
!failedRef.current &&
notebookProjects.length > 0 &&
canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine);
setCapabilityResolved(true);
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.addEventListener("change", updateEngine);
return () => {
reducedMotion.removeEventListener("change", updateEngine);
};
}, [notebookProjects.length]);
useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
useEffect(() => {
const section = sectionRef.current;
if (!engine || !section) return undefined;
if (!("IntersectionObserver" in window)) {
setSectionIntersects(true);
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
{ threshold: 0 },
);
observer.observe(section);
return () => observer.disconnect();
}, [engine]);
useEffect(() => {
if (!engine) return undefined;
const sectionElement = sectionRef.current;
if (!sectionElement) return undefined;
const sectionRoot: HTMLElement = sectionElement;
let cancelled = false;
let cleanup = () => {};
async function setupTimeline() {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
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,
// medidas pelo layout: as transições da home deslocam a seção com transform
start: () => pageTop(sectionRoot),
end: () =>
pageTop(sectionRoot) + sectionRoot.offsetHeight - window.innerHeight,
scrub: true,
invalidateOnRefresh: true,
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 = () => {
seekRef.current = null;
context.revert();
};
}
void setupTimeline().catch(() => {
if (!cancelled) {
failedRef.current = true;
setEngine(false);
}
});
return () => {
cancelled = true;
cleanup();
};
}, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => {
failedRef.current = true;
setEngine(false);
}, []);
const handleSelectTab = useCallback(
(index: number) => {
const safeIndex = clampIndex(index, notebookProjects.length);
if (safeIndex < 0) return;
activeIndexRef.current = safeIndex;
setActiveIndex(safeIndex);
if (engine) {
seekRef.current?.(
getNotebookSeekProgress(safeIndex, notebookProjects.length),
);
}
},
[engine, notebookProjects.length],
);
const activeProject = notebookProjects[activeIndex];
if (!engine) {
return (
<section
aria-label="Projetos em produção no notebook"
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`,
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
} as CSSProperties
}
>
<div className="notebook-story-static-content">
<div className="notebook-story-copy">
<span className="notebook-story-kicker">O notebook</span>
<h2>Software que opera.</h2>
<p>
Uma seleção navegável de sistemas reais, com suas interfaces e
tecnologias em produção.
</p>
</div>
<NotebookFallback
initialIndex={activeIndex}
onSelectTab={handleSelectTab}
projects={notebookProjects}
/>
</div>
</section>
);
}
return (
<section
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`,
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
} as CSSProperties
}
>
<div className="notebook-story-sticky">
<div className="notebook-story-grid">
<div className="notebook-story-copy">
<span className="notebook-story-kicker">O notebook</span>
<h2>Software que opera.</h2>
<p>
Role para abrir a engenharia por trás de cada entrega. O scroll é
o controle de play e rebobinar.
</p>
<span className="notebook-story-count" aria-live="polite">
{String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "}
{String(notebookProjects.length).padStart(2, "0")}
</span>
</div>
<NotebookStage3D
activeIndex={activeIndex}
isVisible={sectionIntersects && isPageVisible}
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
requestRender={requestRenderRef}
projects={notebookProjects}
/>
<nav
className="notebook-story-controls"
aria-label="Navegar projetos do notebook"
>
<div className="notebook-story-project-buttons">
{notebookProjects.map((project, index) => (
<button
key={project.id}
type="button"
aria-label={`Mostrar ${project.title}, ${String(index + 1).padStart(2, "0")}`}
aria-pressed={index === activeIndex}
onClick={() => handleSelectTab(index)}
>
{String(index + 1).padStart(2, "0")}
</button>
))}
</div>
<p aria-live="polite">{activeProject?.title}</p>
{activeProject?.demoAvailability === "unavailable" ? (
<span aria-disabled="true">
Demo indisponível na última verificação
</span>
) : activeProject?.demoAvailability !== "none" &&
activeProject?.demoUrl ? (
<a
href={activeProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto
{activeProject.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
— Disponibilidade não verificada
</span>
)}
</a>
) : null}
</nav>
</div>
</div>
</section>
);
}
@@ -1,82 +0,0 @@
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import { selectNotebookProjects } from "./notebookProjects";
const projectsFixture = getFeaturedProjects(null);
describe("selectNotebookProjects", () => {
it("returns featured projects in notebook order", () => {
const result = selectNotebookProjects(projectsFixture);
expect(result.map((project) => project.id)).toEqual([
"nutriscan",
"sc-plus",
"sev-modaintima",
"as-servicos",
]);
});
it("omits a project when its screenshot mapping is missing", () => {
const result = selectNotebookProjects([
...projectsFixture,
{ ...projectsFixture[0], id: "unknown-live-project" },
]);
expect(
result.some((project) => project.id === "unknown-live-project"),
).toBe(false);
});
it("omits a project with an unapproved demo URL", () => {
const projectsWithUnsafeUrl = projectsFixture.map((project) =>
project.id === "nutriscan"
? { ...project, demoUrl: "javascript:alert(1)" }
: project,
);
const result = selectNotebookProjects(projectsWithUnsafeUrl);
expect(result.some((project) => project.id === "nutriscan")).toBe(false);
});
it("keeps a project without a demo so its local evidence remains visible", () => {
const projectsWithoutDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoUrl: null,
demoAvailability: "none" as const,
demoCheckedAt: null,
}
: project,
) as unknown as PortfolioProjectView[];
expect(
selectNotebookProjects(projectsWithoutDemo).some(
(project) => project.id === "nutriscan",
),
).toBe(true);
});
it("keeps an unavailable project when its local evidence and URL exist", () => {
const projectsWithUnavailableDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
expect(
selectNotebookProjects(projectsWithUnavailableDemo).find(
(project) => project.id === "nutriscan",
),
).toMatchObject({
demoAvailability: "unavailable",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
});
});
});
-69
View File
@@ -1,69 +0,0 @@
import type { PortfolioProjectView } from "@/lib/portfolio-data";
export type NotebookProject = {
id: string;
title: string;
subtitle: string;
demoUrl: PortfolioProjectView["demoUrl"];
demoAvailability: PortfolioProjectView["demoAvailability"];
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
technologies: string[];
screenshotSrc: string;
screenshotWidth: number;
screenshotHeight: number;
};
const NOTEBOOK_ORDER = [
"nutriscan",
"sc-plus",
"sev-modaintima",
"as-servicos",
] as const;
/** Capturas de tela publicadas; os cards de projeto reutilizam as mesmas imagens. */
export const SCREENSHOT_BY_PROJECT_ID: Record<
(typeof NOTEBOOK_ORDER)[number],
string
> = {
nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/notebook/as-servicos.webp",
};
function isApprovedDemoUrl(
value: PortfolioProjectView["demoUrl"],
): value is string {
return typeof value === "string" && /^https?:\/\//.test(value);
}
export function selectNotebookProjects(
projects: readonly PortfolioProjectView[],
): NotebookProject[] {
return NOTEBOOK_ORDER.flatMap((id) => {
const project = projects.find((candidate) => candidate.id === id);
const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id];
if (
!project ||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
) {
return [];
}
return [
{
id: project.id,
title: project.title,
subtitle: project.subtitle,
demoUrl: project.demoUrl,
demoAvailability: project.demoAvailability,
demoCheckedAt: project.demoCheckedAt,
technologies: [...project.technologies],
screenshotSrc,
screenshotWidth: 1600,
screenshotHeight: 1000,
},
];
});
}
@@ -1,75 +0,0 @@
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
describe("notebook choreography", () => {
it("opens and boots before browsing, then blanks the display before closing", () => {
const frames = Array.from({ length: 1001 }, (_, index) =>
getNotebookFrame(index / 1000, 4),
);
const phases = [...new Set(frames.map((frame) => frame.phase))];
expect(phases).toEqual([
"approach",
"open",
"boot",
"cursor-in",
"browse",
"hold",
"close",
]);
for (const frame of frames) {
if (frame.phase === "open") expect(frame.screenOpacity).toBe(0);
if (frame.cursorVisible) expect(frame.screenOpacity).toBe(1);
if (frame.phase === "close" && frame.lidAngle < 112)
expect(frame.screenOpacity).toBe(0);
}
expect(getNotebookFrame(-1, 4).lidAngle).toBe(0);
expect(getNotebookFrame(2, 4).lidAngle).toBe(0);
});
it("keeps the previous project until cursor arrival, including reverse scroll", () => {
const frames = Array.from({ length: 10001 }, (_, index) =>
getNotebookFrame(index / 10000, 4),
);
for (let selected = 1; selected < 4; selected += 1) {
const arrival = frames.findIndex(
(frame) => frame.activeTabIndex === selected,
);
const before = getNotebookFrame((arrival - 1) / 10000, 4);
const atClick = frames[arrival];
expect(before.activeTabIndex).toBe(selected - 1);
expect(before.cursorClicking).toBe(false);
expect(atClick.cursorClicking).toBe(true);
expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
expect(reversed.activeTabIndex).toBe(selected - 1);
expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
}
});
it("seeks directly into a readable hold for the requested project", () => {
for (const count of [1, 4]) {
for (let index = 0; index < count; index += 1) {
const progress = getNotebookSeekProgress(index, count);
const frame = getNotebookFrame(progress, count);
expect(frame.activeTabIndex).toBe(index);
expect(frame.screenOpacity).toBe(1);
expect(frame.cursorClicking).toBe(false);
expect(getNotebookFrame(progress + 0.03, count).activeTabIndex).toBe(
index,
);
}
}
expect(
getNotebookFrame(getNotebookSeekProgress(99, 4), 4).activeTabIndex,
).toBe(3);
});
it("does not expose a tab or cursor for empty or invalid project counts", () => {
expect(getNotebookFrame(0.6, 0)).toMatchObject({
activeTabIndex: -1,
cursorVisible: false,
cursorClicking: false,
});
expect(getNotebookFrame(0.6, Number.NaN).activeTabIndex).toBe(-1);
});
});
-117
View File
@@ -1,117 +0,0 @@
export type NotebookPhase =
| "approach"
| "open"
| "boot"
| "cursor-in"
| "browse"
| "hold"
| "close";
export type NotebookFrame = {
phase: NotebookPhase;
lidAngle: number;
deviceOpacity: number;
screenOpacity: number;
activeTabIndex: number;
cursorVisible: boolean;
cursorX: number;
cursorY: number;
cursorClicking: boolean;
};
const OPEN_START = 0.06;
const BOOT_START = 0.18;
const CURSOR_START = 0.23;
const BROWSE_START = 0.3;
const BROWSE_END = 0.84;
const CLOSE_START = 0.9;
const CLOSE_LID_START = 0.93;
const OPEN_ANGLE = 112;
const ARRIVAL = 0.18;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function between(value: number, start: number, end: number): number {
return clamp((value - start) / (end - start), 0, 1);
}
export function getNotebookSeekProgress(
index: number,
tabCount: number,
): number {
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
if (!count) return BROWSE_START;
const selected = clamp(
Number.isFinite(index) ? Math.floor(index) : 0,
0,
count - 1,
);
return (
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
);
}
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
export function getNotebookFrame(
progress: number,
tabCount: number,
target?: NotebookFrame,
): NotebookFrame {
const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
const phase: NotebookPhase =
p < OPEN_START
? "approach"
: p < BOOT_START
? "open"
: p < CURSOR_START
? "boot"
: p < BROWSE_START
? "cursor-in"
: p < BROWSE_END
? "browse"
: p < CLOSE_START
? "hold"
: "close";
const slot = between(p, BROWSE_START, BROWSE_END) * count;
const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
const local = slot - destination;
const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
const travel = between(local, 0, ARRIVAL);
const cursorTab = moving
? destination - 1 + travel * travel * (3 - 2 * travel)
: destination;
const entry = between(p, CURSOR_START, BROWSE_START);
const frame = target ?? ({} as NotebookFrame);
frame.phase = phase;
frame.lidAngle =
OPEN_ANGLE *
between(p, OPEN_START, BOOT_START) *
(1 - between(p, CLOSE_LID_START, 1));
frame.deviceOpacity = between(p, 0, OPEN_START);
frame.screenOpacity =
between(p, BOOT_START, CURSOR_START) *
(1 - between(p, CLOSE_START, CLOSE_LID_START));
frame.activeTabIndex = activeTabIndex;
frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
frame.cursorX = count
? phase === "cursor-in"
? 0.5 + (0.5 / count - 0.5) * entry
: (cursorTab + 0.5) / count
: 0.5;
frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
frame.cursorClicking =
count > 0 &&
phase === "browse" &&
(destination === 0
? local < 0.07
: local >= ARRIVAL && local < ARRIVAL + 0.07);
return frame;
}
-670
View File
@@ -1315,676 +1315,6 @@ html.lenis {
will-change: transform, opacity;
}
/* Notebook story */
.notebook-story {
position: relative;
overflow: clip;
background: var(--ink);
color: var(--paper);
}
.notebook-story-runway {
position: relative;
min-height: var(--notebook-runway, 560vh);
}
@media (prefers-reduced-motion: no-preference) {
.notebook-story-pending {
min-height: var(--notebook-runway, 560vh);
}
}
.notebook-story-sticky {
position: sticky;
top: 0;
display: grid;
height: 100dvh;
min-height: 100dvh;
place-items: center;
padding: 32px clamp(20px, 3vw, 48px);
}
.notebook-story-grid {
display: grid;
grid-template-columns: minmax(160px, 0.24fr) minmax(0, 1fr);
align-items: center;
gap: clamp(20px, 3vw, 48px);
width: min(1440px, 100%);
}
.notebook-story-copy {
max-width: 360px;
}
.notebook-story-kicker,
.notebook-fallback-kicker,
.notebook-browser-eyebrow {
color: var(--slash);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
line-height: 1.2;
text-transform: uppercase;
}
.notebook-story-copy h2 {
margin-top: var(--sp-4);
max-width: 8ch;
color: var(--white);
font-family: var(--font-display);
font-size: clamp(38px, 5vw, 72px);
font-weight: 500;
letter-spacing: -0.06em;
line-height: 0.94;
}
.notebook-story-copy p {
max-width: 32ch;
margin-top: var(--sp-6);
color: rgba(244, 242, 237, 0.64);
font-size: 13px;
line-height: 1.7;
}
.notebook-story-stage {
position: relative;
display: grid;
min-width: 0;
height: min(82dvh, 780px);
place-items: center;
}
.notebook-story-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.notebook-story-canvas > div:first-child,
.notebook-story-canvas canvas {
pointer-events: none;
}
.notebook-story-controls {
display: none;
}
.notebook-story-static-content {
width: min(1080px, 100%);
margin: 0 auto;
padding: clamp(48px, 9vw, 112px) clamp(20px, 6vw, 80px);
}
.notebook-story-static-content > .notebook-fallback {
width: 100%;
margin: 0;
padding: 0;
}
.notebook-screen-surface {
pointer-events: auto;
overflow: hidden;
border-radius: 2px;
transform-origin: 0 0;
}
.notebook-screen-surface .notebook-browser {
width: 100%;
height: 100%;
box-shadow: none;
}
.notebook-screen-surface .notebook-browser {
display: flex;
flex-direction: column;
}
.notebook-screen-surface .notebook-browser-bar {
min-height: 26px;
}
.notebook-screen-surface .notebook-browser-body {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
padding: 10px 14px;
}
.notebook-screen-surface .notebook-browser-heading {
display: none;
}
.notebook-screen-surface .notebook-tablist {
margin-top: 0;
}
.notebook-screen-surface .notebook-panel {
flex: 1;
min-height: 0;
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 10px;
}
.notebook-screen-surface .notebook-panel .notebook-screenshot-wrap {
grid-row: 1;
align-self: stretch;
width: 100%;
min-height: 0;
aspect-ratio: auto;
}
.notebook-screen-surface .notebook-panel-copy {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 12px;
align-items: center;
}
.notebook-screen-surface .notebook-panel-copy h3 {
font-size: 20px;
}
.notebook-screen-surface .notebook-panel-copy > p {
grid-column: 1;
margin-top: 4px;
font-size: 12px;
}
.notebook-screen-surface .notebook-panel-copy ul {
grid-column: 1;
margin-top: 6px;
}
.notebook-screen-surface .notebook-panel-copy > a,
.notebook-screen-surface .notebook-panel-copy > span {
grid-column: 2;
grid-row: 1 / 4;
max-width: 180px;
margin-top: 0;
font-size: 11px;
}
.notebook-fallback {
width: min(1080px, 100%);
margin: 0 auto;
padding: clamp(48px, 9vw, 112px) clamp(20px, 6vw, 80px);
}
.notebook-fallback-intro {
width: min(560px, 100%);
margin-bottom: var(--sp-8);
}
.notebook-fallback-intro p {
margin-top: var(--sp-3);
color: rgba(244, 242, 237, 0.68);
font-size: 13px;
}
.notebook-browser {
position: relative;
width: min(100%, 940px);
overflow: hidden;
border: 1px solid rgba(244, 242, 237, 0.16);
background: #101313;
color: var(--paper);
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.34);
}
.notebook-browser-bar {
display: flex;
align-items: center;
gap: 6px;
min-height: 36px;
padding: 0 var(--sp-4);
border-bottom: 1px solid rgba(244, 242, 237, 0.1);
background: #181d1d;
}
.notebook-browser-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(244, 242, 237, 0.34);
}
.notebook-browser-dot:first-child {
background: #e46c4d;
}
.notebook-browser-dot:nth-child(2) {
background: #d6aa57;
}
.notebook-browser-dot:nth-child(3) {
background: #7cbd67;
}
.notebook-browser-address {
margin-left: var(--sp-3);
color: rgba(244, 242, 237, 0.42);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
}
.notebook-browser-body {
padding: clamp(20px, 3vw, 32px);
}
.notebook-browser-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
}
.notebook-browser-counter {
color: rgba(244, 242, 237, 0.44);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
}
.notebook-tablist {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 1px;
margin-top: var(--sp-5);
border-block: 1px solid rgba(244, 242, 237, 0.12);
}
.notebook-tab {
display: flex;
min-width: 0;
min-height: 44px;
align-items: center;
gap: var(--sp-2);
padding: 10px;
border: 1px solid transparent;
border-bottom-width: 2px;
background: transparent;
color: rgba(244, 242, 237, 0.48);
cursor: pointer;
text-align: left;
transition:
color 180ms ease,
border-color 180ms ease,
background-color 180ms ease,
transform 160ms var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1));
}
.notebook-tab:hover,
.notebook-tab[aria-selected="true"] {
border-bottom-color: var(--slash);
background: rgba(204, 255, 0, 0.06);
color: var(--white);
}
.notebook-tab:focus-visible {
outline: 2px solid var(--slash);
outline-offset: -3px;
}
.notebook-tab-number {
flex: 0 0 auto;
color: var(--slash);
font-family: var(--font-mono);
font-size: 10px;
}
.notebook-tab-title {
overflow: hidden;
font-family: var(--font-display);
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.notebook-panel {
display: grid;
grid-template-columns: 1fr;
align-items: start;
gap: var(--sp-6);
margin-top: var(--sp-6);
}
.notebook-panel-copy {
order: 2;
}
.notebook-panel-copy h3 {
color: var(--white);
font-family: var(--font-display);
font-size: clamp(22px, 3vw, 36px);
font-weight: 500;
letter-spacing: -0.04em;
line-height: 1;
}
.notebook-panel-copy > p {
margin-top: var(--sp-2);
color: rgba(244, 242, 237, 0.6);
font-size: 12px;
line-height: 1.5;
}
.notebook-panel-copy ul {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: var(--sp-4);
list-style: none;
}
.notebook-panel-copy li {
padding: 4px 7px;
border: 1px solid rgba(244, 242, 237, 0.15);
color: rgba(244, 242, 237, 0.7);
font-family: var(--font-mono);
font-size: 9px;
line-height: 1.2;
}
.notebook-panel-copy a {
display: inline-flex;
min-height: 44px;
align-items: center;
gap: 6px;
margin-top: var(--sp-5);
padding: 0 14px;
border: 1px solid var(--slash);
background: var(--slash);
color: var(--ink);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-decoration: none;
text-transform: uppercase;
transition:
background-color 180ms ease,
border-color 180ms ease,
transform 180ms ease;
}
.notebook-panel-copy a:hover {
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 {
position: relative;
overflow: hidden;
aspect-ratio: 8 / 5;
border: 1px solid rgba(244, 242, 237, 0.16);
background: var(--paper-dark);
}
.notebook-screenshot-status {
position: absolute;
z-index: 2;
inset: auto 0 0;
padding: 10px 14px;
background: rgba(10, 10, 10, 0.92);
color: var(--paper);
font-size: 12px;
line-height: 1.4;
}
.notebook-screenshot-wrap img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.notebook-screenshot {
position: absolute;
inset: 0;
}
.notebook-screenshot-current {
z-index: 1;
animation: notebook-screenshot-in 220ms ease both;
}
.notebook-screenshot-outgoing {
z-index: 0;
}
.notebook-screenshot-pending {
opacity: 0;
pointer-events: none;
}
@keyframes notebook-screenshot-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.notebook-cursor {
position: absolute;
top: 0;
left: 0;
z-index: 3;
width: 28px;
height: 36px;
overflow: visible;
opacity: 0;
pointer-events: none;
transform-origin: 1px 1px;
filter: drop-shadow(1px 2px 1px rgba(0, 0, 0, 0.4));
}
.notebook-cursor-pulse {
opacity: 0;
}
.notebook-cursor.is-clicking .notebook-cursor-pulse {
opacity: 0.7;
}
@media (max-width: 1024px) {
.notebook-story-runway {
min-height: var(--notebook-mobile-runway, 440svh);
}
.notebook-story-sticky {
height: 100svh;
min-height: 100svh;
padding: 64px 20px 16px;
}
.notebook-story-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.notebook-story-copy {
max-width: 560px;
}
.notebook-story-copy h2 {
max-width: 12ch;
margin-top: 8px;
font-size: clamp(32px, 6vw, 44px);
}
.notebook-story-copy p {
max-width: 44ch;
margin-top: 8px;
line-height: 1.45;
}
.notebook-story-stage {
height: min(68vw, 38svh);
min-height: 0;
}
.notebook-story-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px 12px;
min-width: 0;
}
.notebook-story-project-buttons {
display: flex;
grid-column: 1 / -1;
gap: 8px;
}
.notebook-story-project-buttons button,
.notebook-story-controls > a {
display: inline-flex;
min-width: 44px;
min-height: 44px;
align-items: center;
justify-content: center;
padding: 8px 12px;
border: 1px solid rgba(244, 242, 237, 0.24);
color: var(--white);
font-family: var(--font-mono);
font-size: 11px;
text-decoration: none;
touch-action: manipulation;
}
.notebook-story-project-buttons button[aria-pressed="true"] {
border-color: var(--slash);
color: var(--slash);
}
.notebook-story-project-buttons button:focus-visible,
.notebook-story-controls > a:focus-visible {
outline: 2px solid var(--slash);
outline-offset: 3px;
}
.notebook-story-controls > p {
min-width: 0;
font-size: 14px;
overflow-wrap: anywhere;
}
.notebook-story-controls > span {
max-width: 18ch;
font-size: 10px;
}
}
@media (max-width: 1024px) and (prefers-reduced-motion: no-preference) {
.notebook-story-pending {
min-height: var(--notebook-mobile-runway, 440svh);
}
}
@media (max-width: 680px) {
.notebook-story-sticky {
padding-inline: 20px;
}
.notebook-story-copy h2 {
font-size: clamp(32px, 8vw, 44px);
}
.notebook-fallback {
padding-inline: 20px;
}
.notebook-browser-body {
padding: 16px;
}
.notebook-tablist {
grid-auto-flow: row;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.notebook-tab {
padding: 12px 8px;
}
.notebook-tab-title {
white-space: normal;
}
.notebook-screen-surface .notebook-tablist {
grid-auto-flow: column;
grid-template-columns: none;
}
.notebook-screen-surface .notebook-tab-title {
white-space: nowrap;
}
.notebook-panel {
grid-template-columns: 1fr;
}
.notebook-panel-copy {
order: 2;
}
}
@media (max-width: 1024px) and (max-height: 520px) and (orientation: landscape) {
.notebook-story-grid {
grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr);
}
.notebook-story-copy h2 {
font-size: 28px;
}
.notebook-story-copy p {
display: none;
}
.notebook-story-stage {
height: 48svh;
}
.notebook-story-controls {
grid-column: 1 / -1;
grid-template-columns: auto minmax(0, 1fr) auto;
}
.notebook-story-project-buttons {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.notebook-tab,
.notebook-cursor,
.notebook-screenshot-current,
.notebook-screenshot-outgoing {
transition: none;
animation: none;
}
}
/* Interactive quality laboratory — scoped to the Lighthouse section. */
.quality-lab {
--quality-muted: color-mix(in srgb, var(--paper) 72%, transparent);