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 Image from "next/image";
|
||||||
import { SCREENSHOT_BY_PROJECT_ID } from "./story/notebookProjects";
|
|
||||||
|
|
||||||
type CoverProject = { id: string; title: string };
|
type CoverProject = { id: string; title: string };
|
||||||
|
|
||||||
const SCREENSHOTS: Record<string, string | undefined> =
|
/** Capturas de tela publicadas dos projetos que têm interface pública. */
|
||||||
SCREENSHOT_BY_PROJECT_ID;
|
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) {
|
function initials(title: string) {
|
||||||
return title
|
return title
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function HomeNavigation() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={onLayoutReady}>
|
<button type="button" onClick={onLayoutReady}>
|
||||||
Establish notebook layout
|
Establish layout
|
||||||
</button>
|
</button>
|
||||||
<a href="/#about">Sobre</a>
|
<a href="/#about">Sobre</a>
|
||||||
<a href="/#contact">Contato</a>
|
<a href="/#contact">Contato</a>
|
||||||
@@ -42,7 +42,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("home fragment navigation", () => {
|
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;
|
let restoredPosition = 3200;
|
||||||
jest
|
jest
|
||||||
.spyOn(window, "scrollTo")
|
.spyOn(window, "scrollTo")
|
||||||
@@ -52,7 +52,7 @@ describe("home fragment navigation", () => {
|
|||||||
});
|
});
|
||||||
render(<HomeNavigation />);
|
render(<HomeNavigation />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
expect(restoredPosition).toBe(0);
|
expect(restoredPosition).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -63,7 +63,7 @@ describe("home fragment navigation", () => {
|
|||||||
.mockImplementation(() => {});
|
.mockImplementation(() => {});
|
||||||
const firstVisit = render(<HomeNavigation />);
|
const firstVisit = render(<HomeNavigation />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
firstVisit.unmount();
|
firstVisit.unmount();
|
||||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||||
@@ -71,7 +71,7 @@ describe("home fragment navigation", () => {
|
|||||||
scrollTo.mockClear();
|
scrollTo.mockClear();
|
||||||
render(<HomeNavigation />);
|
render(<HomeNavigation />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
expect(scrollTo).not.toHaveBeenCalled();
|
expect(scrollTo).not.toHaveBeenCalled();
|
||||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||||
@@ -88,24 +88,24 @@ describe("home fragment navigation", () => {
|
|||||||
position = 600;
|
position = 600;
|
||||||
fireEvent.wheel(window, { deltaY: 600 });
|
fireEvent.wheel(window, { deltaY: 600 });
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
expect(position).toBe(600);
|
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");
|
window.history.replaceState(null, "", "/#contact");
|
||||||
render(<HomeNavigation />);
|
render(<HomeNavigation />);
|
||||||
const contact = screen.getByRole("region", { name: "Contato" });
|
const contact = screen.getByRole("region", { name: "Contato" });
|
||||||
expect(contact).not.toHaveFocus();
|
expect(contact).not.toHaveFocus();
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
expect(contact).toHaveFocus();
|
expect(contact).toHaveFocus();
|
||||||
screen.getByRole("button", { name: "Read biography" }).focus();
|
screen.getByRole("button", { name: "Read biography" }).focus();
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Read biography" }),
|
screen.getByRole("button", { name: "Read biography" }),
|
||||||
@@ -119,7 +119,7 @@ describe("home fragment navigation", () => {
|
|||||||
message.focus();
|
message.focus();
|
||||||
fireEvent.wheel(window, { deltaY: 200 });
|
fireEvent.wheel(window, { deltaY: 200 });
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
expect(message).toHaveFocus();
|
expect(message).toHaveFocus();
|
||||||
});
|
});
|
||||||
@@ -128,7 +128,7 @@ describe("home fragment navigation", () => {
|
|||||||
window.history.replaceState(null, "", "/#about");
|
window.history.replaceState(null, "", "/#about");
|
||||||
render(<HomeNavigation />);
|
render(<HomeNavigation />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
const link = screen.getByRole("link", { name: "Sobre" });
|
const link = screen.getByRole("link", { name: "Sobre" });
|
||||||
link.focus();
|
link.focus();
|
||||||
@@ -152,7 +152,7 @@ describe("home fragment navigation", () => {
|
|||||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||||
});
|
});
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
screen.getByRole("button", { name: "Establish layout" }),
|
||||||
);
|
);
|
||||||
expect(message).toHaveFocus();
|
expect(message).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ import Hero, { type HeroStats } from "../Hero";
|
|||||||
import LighthouseMetrics from "../LighthouseMetrics";
|
import LighthouseMetrics from "../LighthouseMetrics";
|
||||||
import Projects from "../Projects";
|
import Projects from "../Projects";
|
||||||
import Skills from "../Skills";
|
import Skills from "../Skills";
|
||||||
import NotebookStory from "../story/NotebookStory";
|
|
||||||
import { WorkModalities } from "../WorkModalities";
|
import { WorkModalities } from "../WorkModalities";
|
||||||
import { useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import ExperienceDisclosure from "./ExperienceDisclosure";
|
import ExperienceDisclosure from "./ExperienceDisclosure";
|
||||||
import { useCinematicScroll } from "./useCinematicScroll";
|
import { useCinematicScroll } from "./useCinematicScroll";
|
||||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||||
@@ -35,6 +34,8 @@ export default function CinematicHome({
|
|||||||
const onLayoutReady = useHomeFragmentNavigation();
|
const onLayoutReady = useHomeFragmentNavigation();
|
||||||
const mainRef = useRef<HTMLElement>(null);
|
const mainRef = useRef<HTMLElement>(null);
|
||||||
useCinematicScroll(mainRef);
|
useCinematicScroll(mainRef);
|
||||||
|
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
|
||||||
|
useEffect(() => onLayoutReady(), [onLayoutReady]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
<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">
|
<main ref={mainRef} className="ds-main relative z-0">
|
||||||
<Hero stats={heroStats} />
|
<Hero stats={heroStats} />
|
||||||
|
|
||||||
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
|
|
||||||
|
|
||||||
<Projects projects={projects} />
|
<Projects projects={projects} />
|
||||||
|
|
||||||
<WorkModalities />
|
<WorkModalities />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function HeroField() {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [failed, setFailed] = 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);
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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"
|
// 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) =>
|
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
|
||||||
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
|
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
|
// uma linguagem diferente por passagem, na ordem das seções da home
|
||||||
const TRANSITIONS: Transition[] = [
|
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) => {
|
(tl, prev, next) => {
|
||||||
holdPrev(tl, prev);
|
holdPrev(tl, prev);
|
||||||
holdNext(tl, next);
|
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(
|
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,
|
next,
|
||||||
{ clipPath: "inset(0% 5% 0% 5% round 40px 40px 0px 0px)" },
|
{ opacity: 0, clipPath: band(0.36) },
|
||||||
{ clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
|
{ opacity: 1, clipPath: band(1), duration: 0.65 },
|
||||||
0,
|
0.35,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
|
// 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 scenes = Array.from(main.children) as HTMLElement[];
|
||||||
const context = gsap.context(() => {
|
const context = gsap.context(() => {
|
||||||
scenes.forEach((scene, index) => {
|
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 });
|
gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 });
|
||||||
});
|
});
|
||||||
TRANSITIONS.forEach((transition, index) => {
|
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;
|
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. */
|
/* Interactive quality laboratory — scoped to the Lighthouse section. */
|
||||||
.quality-lab {
|
.quality-lab {
|
||||||
--quality-muted: color-mix(in srgb, var(--paper) 72%, transparent);
|
--quality-muted: color-mix(in srgb, var(--paper) 72%, transparent);
|
||||||
|
|||||||
Reference in new issue
Block a user