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:
1 parent
92c602c0ad
commit
44efcfcb20
18 files changed
+30
-3059
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user