From 44efcfcb207dd9d4093a5fd430d0ff7952ec8e6c Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Mon, 5 Oct 2026 01:12:08 -0300 Subject: [PATCH] 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 --- app/components/ProjectCover.tsx | 10 +- .../cinematic/CinematicHome.test.tsx | 24 +- app/components/cinematic/CinematicHome.tsx | 7 +- app/components/cinematic/HeroField.tsx | 2 +- .../cinematic/useCinematicScroll.ts | 29 +- app/components/story/NotebookBrowser.test.tsx | 285 -------- app/components/story/NotebookBrowser.tsx | 427 ----------- .../story/NotebookFallback.test.tsx | 43 -- app/components/story/NotebookFallback.tsx | 52 -- app/components/story/NotebookStage3D.test.tsx | 131 ---- app/components/story/NotebookStage3D.tsx | 468 ------------ app/components/story/NotebookStory.test.tsx | 265 ------- app/components/story/NotebookStory.tsx | 333 --------- app/components/story/notebookProjects.test.ts | 82 --- app/components/story/notebookProjects.ts | 69 -- app/components/story/notebookTimeline.test.ts | 75 -- app/components/story/notebookTimeline.ts | 117 --- app/globals.css | 670 ------------------ 18 files changed, 30 insertions(+), 3059 deletions(-) delete mode 100644 app/components/story/NotebookBrowser.test.tsx delete mode 100644 app/components/story/NotebookBrowser.tsx delete mode 100644 app/components/story/NotebookFallback.test.tsx delete mode 100644 app/components/story/NotebookFallback.tsx delete mode 100644 app/components/story/NotebookStage3D.test.tsx delete mode 100644 app/components/story/NotebookStage3D.tsx delete mode 100644 app/components/story/NotebookStory.test.tsx delete mode 100644 app/components/story/NotebookStory.tsx delete mode 100644 app/components/story/notebookProjects.test.ts delete mode 100644 app/components/story/notebookProjects.ts delete mode 100644 app/components/story/notebookTimeline.test.ts delete mode 100644 app/components/story/notebookTimeline.ts diff --git a/app/components/ProjectCover.tsx b/app/components/ProjectCover.tsx index 67fca2c..1b309e4 100644 --- a/app/components/ProjectCover.tsx +++ b/app/components/ProjectCover.tsx @@ -1,10 +1,14 @@ import Image from "next/image"; -import { SCREENSHOT_BY_PROJECT_ID } from "./story/notebookProjects"; type CoverProject = { id: string; title: string }; -const SCREENSHOTS: Record = - SCREENSHOT_BY_PROJECT_ID; +/** Capturas de tela publicadas dos projetos que têm interface pública. */ +const SCREENSHOTS: Record = { + nutriscan: "/assets/notebook/nutriscan.webp", + "sc-plus": "/assets/notebook/sc-plus.webp", + "sev-modaintima": "/assets/notebook/sev-modaintima.webp", + "as-servicos": "/assets/notebook/as-servicos.webp", +}; function initials(title: string) { return title diff --git a/app/components/cinematic/CinematicHome.test.tsx b/app/components/cinematic/CinematicHome.test.tsx index 64a5b4e..e9c4349 100644 --- a/app/components/cinematic/CinematicHome.test.tsx +++ b/app/components/cinematic/CinematicHome.test.tsx @@ -9,7 +9,7 @@ function HomeNavigation() { return ( <> Sobre Contato @@ -42,7 +42,7 @@ afterEach(() => { }); describe("home fragment navigation", () => { - it("starts an unfragmented visit at the top after the notebook establishes layout", () => { + it("starts an unfragmented visit at the top after the home establishes layout", () => { let restoredPosition = 3200; jest .spyOn(window, "scrollTo") @@ -52,7 +52,7 @@ describe("home fragment navigation", () => { }); render(); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); expect(restoredPosition).toBe(0); }); @@ -63,7 +63,7 @@ describe("home fragment navigation", () => { .mockImplementation(() => {}); const firstVisit = render(); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); firstVisit.unmount(); expect(window.history.scrollRestoration).not.toBe("manual"); @@ -71,7 +71,7 @@ describe("home fragment navigation", () => { scrollTo.mockClear(); render(); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); expect(scrollTo).not.toHaveBeenCalled(); expect(window.history.scrollRestoration).not.toBe("manual"); @@ -88,24 +88,24 @@ describe("home fragment navigation", () => { position = 600; fireEvent.wheel(window, { deltaY: 600 }); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); expect(position).toBe(600); }); - it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => { + it("waits for the home layout on fragment arrival, then never steals focus on refresh", () => { window.history.replaceState(null, "", "/#contact"); render(); const contact = screen.getByRole("region", { name: "Contato" }); expect(contact).not.toHaveFocus(); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); expect(contact).toHaveFocus(); screen.getByRole("button", { name: "Read biography" }).focus(); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); expect( screen.getByRole("button", { name: "Read biography" }), @@ -119,7 +119,7 @@ describe("home fragment navigation", () => { message.focus(); fireEvent.wheel(window, { deltaY: 200 }); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); expect(message).toHaveFocus(); }); @@ -128,7 +128,7 @@ describe("home fragment navigation", () => { window.history.replaceState(null, "", "/#about"); render(); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); const link = screen.getByRole("link", { name: "Sobre" }); link.focus(); @@ -152,7 +152,7 @@ describe("home fragment navigation", () => { window.dispatchEvent(new HashChangeEvent("hashchange")); }); fireEvent.click( - screen.getByRole("button", { name: "Establish notebook layout" }), + screen.getByRole("button", { name: "Establish layout" }), ); expect(message).toHaveFocus(); }); diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx index f367b40..88ae374 100644 --- a/app/components/cinematic/CinematicHome.tsx +++ b/app/components/cinematic/CinematicHome.tsx @@ -14,9 +14,8 @@ import Hero, { type HeroStats } from "../Hero"; import LighthouseMetrics from "../LighthouseMetrics"; import Projects from "../Projects"; import Skills from "../Skills"; -import NotebookStory from "../story/NotebookStory"; import { WorkModalities } from "../WorkModalities"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import ExperienceDisclosure from "./ExperienceDisclosure"; import { useCinematicScroll } from "./useCinematicScroll"; import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation"; @@ -35,6 +34,8 @@ export default function CinematicHome({ const onLayoutReady = useHomeFragmentNavigation(); const mainRef = useRef(null); useCinematicScroll(mainRef); + // o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida + useEffect(() => onLayoutReady(), [onLayoutReady]); return (
@@ -42,8 +43,6 @@ export default function CinematicHome({
- - diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx index f18b378..e0d52dc 100644 --- a/app/components/cinematic/HeroField.tsx +++ b/app/components/cinematic/HeroField.tsx @@ -38,7 +38,7 @@ export default function HeroField() { const [visible, setVisible] = useState(false); const [ready, setReady] = useState(false); const [failed, setFailed] = useState(false); - // fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU para o notebook + // fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU const [done, setDone] = useState(false); useEffect(() => { diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts index 08abd38..c441148 100644 --- a/app/components/cinematic/useCinematicScroll.ts +++ b/app/components/cinematic/useCinematicScroll.ts @@ -36,35 +36,20 @@ const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) => // a seção que entra também fica parada no topo: a transição acontece "no lugar" const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) => tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0); -const stageOf = (section: HTMLElement) => - section.querySelector(".notebook-story-sticky") ?? section; // uma linguagem diferente por passagem, na ordem das seções da home const TRANSITIONS: Transition[] = [ - // hero → notebook: o mergulho termina no escuro, o hero some e o notebook abre como tela de cinema + // hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); + const band = (open: number) => () => + `inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`; tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo( - stageOf(next), - { opacity: 0, clipPath: "inset(32% 0% 32% 0%)" }, - { opacity: 1, clipPath: "inset(0% 0% 0% 0%)", duration: 0.65 }, - 0.35, - ); - }, - // notebook → projetos: a seção sobe como um cartão e o notebook recolhe ao fundo - (tl, prev, next) => { - holdPrev(tl, prev); - tl.fromTo( - stageOf(prev), - { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }, - { opacity: 0.15, clipPath: "inset(8% 6% 8% 6% round 32px)" }, - 0, - ).fromTo( next, - { clipPath: "inset(0% 5% 0% 5% round 40px 40px 0px 0px)" }, - { clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" }, - 0, + { opacity: 0, clipPath: band(0.36) }, + { opacity: 1, clipPath: band(1), duration: 0.65 }, + 0.35, ); }, // projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera @@ -197,7 +182,7 @@ export function useCinematicScroll(mainRef: RefObject) { const scenes = Array.from(main.children) as HTMLElement[]; const context = gsap.context(() => { scenes.forEach((scene, index) => { - // quem entra fica por cima; o hero, que sai por dissolução, fica por cima do notebook + // quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 }); }); TRANSITIONS.forEach((transition, index) => { diff --git a/app/components/story/NotebookBrowser.test.tsx b/app/components/story/NotebookBrowser.test.tsx deleted file mode 100644 index 716d9db..0000000 --- a/app/components/story/NotebookBrowser.test.tsx +++ /dev/null @@ -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 = {}) { - return render( - , - ); -} - -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( - , - ); - let finishDecode!: () => void; - imageFor(view.container, 1).decode = jest - .fn() - .mockResolvedValueOnce(undefined) - .mockImplementation( - () => - new Promise((resolve) => { - finishDecode = resolve; - }), - ); - await act(async () => { - fireEvent.load(imageFor(view.container, 1)); - }); - expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i); - - view.rerender( - , - ); - 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( - , - ); - 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( - , - ); - await act(async () => { - fireEvent.load(imageFor(view.container, 1)); - }); - view.rerender( - , - ); - 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( - '[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, - ); - }); -}); diff --git a/app/components/story/NotebookBrowser.tsx b/app/components/story/NotebookBrowser.tsx deleted file mode 100644 index 79fa9ff..0000000 --- a/app/components/story/NotebookBrowser.tsx +++ /dev/null @@ -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; - 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(null); - const cursorRef = useRef(null); - const cursorFrame = useRef(frame); - const tabPoints = useRef>([]); - const browserSize = useRef({ width: 0, height: 0 }); - const [displayed, setDisplayed] = useState(null); - const [outgoing, setOutgoing] = useState(null); - const [failedSrc, setFailedSrc] = useState(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( - `#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, - 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('[role="tab"]') - [nextIndex]?.focus({ preventScroll: true }); - onSelectTab(nextIndex); - } - - if (!selectedProject) { - return ( -
-

Nenhum projeto disponível para esta cena.

-
- ); - } - - const selectedId = toDomId(selectedProject.id); - const panelId = `notebook-panel-${selectedId}`; - - return ( -
- - -
-
- Projetos em produção - - {String(selectedIndex + 1).padStart(2, "0")} /{" "} - {String(projects.length).padStart(2, "0")} - -
- - Projeto ativo: {selectedProject.title} - - -
- {projects.map((project, index) => { - const tabId = `notebook-tab-${toDomId(project.id)}`; - const isSelected = index === selectedIndex; - - return ( - - ); - })} -
- -
-
-

{selectedProject.title}

-

{selectedProject.subtitle}

-
    - {selectedProject.technologies.slice(0, 4).map((technology) => ( -
  • {technology}
  • - ))} -
- {selectedProject.demoAvailability === "unavailable" ? ( - - Demo indisponível na última verificação - - ) : selectedProject.demoAvailability === "none" || - !selectedProject.demoUrl ? null : ( - - Abrir projeto - {selectedProject.demoAvailability === "unknown" ? ( - - {" "} - Disponibilidade não verificada - - ) : null} - - )} -
- -
- {imageProjects.map((project) => { - const current = - project.screenshotSrc === displayed?.screenshotSrc; - const previous = - project.screenshotSrc === outgoing?.screenshotSrc; - return ( - {current { - void revealImage(event.currentTarget, project); - }} - onError={() => { - if (project.screenshotSrc === requestRef.current) - setFailedSrc(project.screenshotSrc); - }} - onAnimationEnd={() => { - if (current) setOutgoing(null); - }} - /> - ); - })} - {waiting ? ( -

- {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} -

- ) : null} -
-
-
- -
- ); -} diff --git a/app/components/story/NotebookFallback.test.tsx b/app/components/story/NotebookFallback.test.tsx deleted file mode 100644 index b104e45..0000000 --- a/app/components/story/NotebookFallback.test.tsx +++ /dev/null @@ -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(); - - 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(); - - 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(); - - expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/AS Serviços/i); - expect(screen.getAllByRole("tab")[3]).toHaveAttribute( - "aria-selected", - "true", - ); - }); -}); diff --git a/app/components/story/NotebookFallback.tsx b/app/components/story/NotebookFallback.tsx deleted file mode 100644 index 6e03cb9..0000000 --- a/app/components/story/NotebookFallback.tsx +++ /dev/null @@ -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 ( -
-
- Interface em produção -

- Navegue pelos projetos com as abas abaixo. A experiência permanece - completa sem movimento ou WebGL. -

-
- { - setActiveIndex(index); - onSelectTab?.(index); - }} - projects={projects} - /> -
- ); -} diff --git a/app/components/story/NotebookStage3D.test.tsx b/app/components/story/NotebookStage3D.test.tsx deleted file mode 100644 index ab9e7e6..0000000 --- a/app/components/story/NotebookStage3D.test.tsx +++ /dev/null @@ -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) => ( -
- {children} -
-)); - -jest.mock("@react-three/fiber", () => ({ - Canvas: (props: MockCanvasProps) => mockCanvas(props), - useFrame: jest.fn(), -})); - -jest.mock("@react-three/drei", () => ({ - Html: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - 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(); - - 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); - }); -}); diff --git a/app/components/story/NotebookStage3D.tsx b/app/components/story/NotebookStage3D.tsx deleted file mode 100644 index 5774b71..0000000 --- a/app/components/story/NotebookStage3D.tsx +++ /dev/null @@ -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(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 ( - - - - ); -} - -function LaptopBase({ materials }: { materials: LaptopMaterials }) { - return ( - - - - - - - - - - - - - ); -} - -function LaptopLid({ materials }: { materials: LaptopMaterials }) { - return ( - - - - - ); -} - -function ScreenSurface({ - frame, - material, - projects, - activeIndex, - onSelectTab, -}: { - frame: RefObject; - material: THREE.MeshStandardMaterial; - projects: NotebookProject[]; - activeIndex: number; - onSelectTab: (index: number) => void; -}) { - const screen = useRef(null); - const surface = useRef(null); - const browser = useRef(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 ( - - - - - -
- -
- -
- ); -} - -function NotebookScene({ - activeIndex, - onSelectTab, - progress, - projects, - requestRender, - isVisible, - onStageError, -}: NotebookSceneProps) { - const lidRig = useRef(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 ( - - - - - - - - - - - - ); -} - -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 ( - - ); - } - - return ( - - - - ); - } -} - -export default function NotebookStage3D(props: NotebookStage3DProps) { - return ( -
- -
- ); -} diff --git a/app/components/story/NotebookStory.test.tsx b/app/components/story/NotebookStory.test.tsx deleted file mode 100644 index 2563616..0000000 --- a/app/components/story/NotebookStory.test.tsx +++ /dev/null @@ -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 }) => ( -
-)); - -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(); - - 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(); - - 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(); - 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(); - 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(); - - await waitFor(() => - expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(), - ); - }); - - it("stays paused when the tab returns while the section is offscreen", async () => { - render(); - - 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(); - 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(); - - 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(); - - expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument(); - expect( - screen.queryByRole("tab", { name: /Twins Lab/i }), - ).not.toBeInTheDocument(); - }); -}); diff --git a/app/components/story/NotebookStory.tsx b/app/components/story/NotebookStory.tsx deleted file mode 100644 index e555062..0000000 --- a/app/components/story/NotebookStory.tsx +++ /dev/null @@ -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; - -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(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 ( -
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 - } - > -
-
- O notebook -

Software que opera.

-

- Uma seleção navegável de sistemas reais, com suas interfaces e - tecnologias em produção. -

-
- -
-
- ); - } - - return ( -
-
-
-
- O notebook -

Software que opera.

-

- Role para abrir a engenharia por trás de cada entrega. O scroll é - o controle de play e rebobinar. -

- - {String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "} - {String(notebookProjects.length).padStart(2, "0")} - -
- - -
-
-
- ); -} diff --git a/app/components/story/notebookProjects.test.ts b/app/components/story/notebookProjects.test.ts deleted file mode 100644 index 64ce009..0000000 --- a/app/components/story/notebookProjects.test.ts +++ /dev/null @@ -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", - }); - }); -}); diff --git a/app/components/story/notebookProjects.ts b/app/components/story/notebookProjects.ts deleted file mode 100644 index afa19b2..0000000 --- a/app/components/story/notebookProjects.ts +++ /dev/null @@ -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, - }, - ]; - }); -} diff --git a/app/components/story/notebookTimeline.test.ts b/app/components/story/notebookTimeline.test.ts deleted file mode 100644 index d0d2b78..0000000 --- a/app/components/story/notebookTimeline.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/app/components/story/notebookTimeline.ts b/app/components/story/notebookTimeline.ts deleted file mode 100644 index 8095023..0000000 --- a/app/components/story/notebookTimeline.ts +++ /dev/null @@ -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; -} diff --git a/app/globals.css b/app/globals.css index d8db6e4..1d2c24b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1315,676 +1315,6 @@ html.lenis { will-change: transform, opacity; } -/* Notebook story */ -.notebook-story { - position: relative; - overflow: clip; - background: var(--ink); - color: var(--paper); -} - -.notebook-story-runway { - position: relative; - min-height: var(--notebook-runway, 560vh); -} - -@media (prefers-reduced-motion: no-preference) { - .notebook-story-pending { - min-height: var(--notebook-runway, 560vh); - } -} - -.notebook-story-sticky { - position: sticky; - top: 0; - display: grid; - height: 100dvh; - min-height: 100dvh; - place-items: center; - padding: 32px clamp(20px, 3vw, 48px); -} - -.notebook-story-grid { - display: grid; - grid-template-columns: minmax(160px, 0.24fr) minmax(0, 1fr); - align-items: center; - gap: clamp(20px, 3vw, 48px); - width: min(1440px, 100%); -} - -.notebook-story-copy { - max-width: 360px; -} - -.notebook-story-kicker, -.notebook-fallback-kicker, -.notebook-browser-eyebrow { - color: var(--slash); - font-family: var(--font-mono); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.16em; - line-height: 1.2; - text-transform: uppercase; -} - -.notebook-story-copy h2 { - margin-top: var(--sp-4); - max-width: 8ch; - color: var(--white); - font-family: var(--font-display); - font-size: clamp(38px, 5vw, 72px); - font-weight: 500; - letter-spacing: -0.06em; - line-height: 0.94; -} - -.notebook-story-copy p { - max-width: 32ch; - margin-top: var(--sp-6); - color: rgba(244, 242, 237, 0.64); - font-size: 13px; - line-height: 1.7; -} - -.notebook-story-stage { - position: relative; - display: grid; - min-width: 0; - height: min(82dvh, 780px); - place-items: center; -} - -.notebook-story-canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} -.notebook-story-canvas > div:first-child, -.notebook-story-canvas canvas { - pointer-events: none; -} - -.notebook-story-controls { - display: none; -} - -.notebook-story-static-content { - width: min(1080px, 100%); - margin: 0 auto; - padding: clamp(48px, 9vw, 112px) clamp(20px, 6vw, 80px); -} - -.notebook-story-static-content > .notebook-fallback { - width: 100%; - margin: 0; - padding: 0; -} - -.notebook-screen-surface { - pointer-events: auto; - overflow: hidden; - border-radius: 2px; - transform-origin: 0 0; -} - -.notebook-screen-surface .notebook-browser { - width: 100%; - height: 100%; - box-shadow: none; -} - -.notebook-screen-surface .notebook-browser { - display: flex; - flex-direction: column; -} - -.notebook-screen-surface .notebook-browser-bar { - min-height: 26px; -} - -.notebook-screen-surface .notebook-browser-body { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; - padding: 10px 14px; -} - -.notebook-screen-surface .notebook-browser-heading { - display: none; -} - -.notebook-screen-surface .notebook-tablist { - margin-top: 0; -} - -.notebook-screen-surface .notebook-panel { - flex: 1; - min-height: 0; - grid-template-columns: 1fr; - grid-template-rows: minmax(0, 1fr) auto; - gap: 10px; - margin-top: 10px; -} - -.notebook-screen-surface .notebook-panel .notebook-screenshot-wrap { - grid-row: 1; - align-self: stretch; - width: 100%; - min-height: 0; - aspect-ratio: auto; -} - -.notebook-screen-surface .notebook-panel-copy { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - column-gap: 12px; - align-items: center; -} - -.notebook-screen-surface .notebook-panel-copy h3 { - font-size: 20px; -} - -.notebook-screen-surface .notebook-panel-copy > p { - grid-column: 1; - margin-top: 4px; - font-size: 12px; -} - -.notebook-screen-surface .notebook-panel-copy ul { - grid-column: 1; - margin-top: 6px; -} - -.notebook-screen-surface .notebook-panel-copy > a, -.notebook-screen-surface .notebook-panel-copy > span { - grid-column: 2; - grid-row: 1 / 4; - max-width: 180px; - margin-top: 0; - font-size: 11px; -} - -.notebook-fallback { - width: min(1080px, 100%); - margin: 0 auto; - padding: clamp(48px, 9vw, 112px) clamp(20px, 6vw, 80px); -} - -.notebook-fallback-intro { - width: min(560px, 100%); - margin-bottom: var(--sp-8); -} - -.notebook-fallback-intro p { - margin-top: var(--sp-3); - color: rgba(244, 242, 237, 0.68); - font-size: 13px; -} - -.notebook-browser { - position: relative; - width: min(100%, 940px); - overflow: hidden; - border: 1px solid rgba(244, 242, 237, 0.16); - background: #101313; - color: var(--paper); - box-shadow: 0 26px 80px rgba(0, 0, 0, 0.34); -} - -.notebook-browser-bar { - display: flex; - align-items: center; - gap: 6px; - min-height: 36px; - padding: 0 var(--sp-4); - border-bottom: 1px solid rgba(244, 242, 237, 0.1); - background: #181d1d; -} - -.notebook-browser-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: rgba(244, 242, 237, 0.34); -} - -.notebook-browser-dot:first-child { - background: #e46c4d; -} - -.notebook-browser-dot:nth-child(2) { - background: #d6aa57; -} - -.notebook-browser-dot:nth-child(3) { - background: #7cbd67; -} - -.notebook-browser-address { - margin-left: var(--sp-3); - color: rgba(244, 242, 237, 0.42); - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.04em; -} - -.notebook-browser-body { - padding: clamp(20px, 3vw, 32px); -} - -.notebook-browser-heading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--sp-4); -} - -.notebook-browser-counter { - color: rgba(244, 242, 237, 0.44); - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.08em; -} - -.notebook-tablist { - display: grid; - grid-auto-flow: column; - grid-auto-columns: minmax(0, 1fr); - gap: 1px; - margin-top: var(--sp-5); - border-block: 1px solid rgba(244, 242, 237, 0.12); -} - -.notebook-tab { - display: flex; - min-width: 0; - min-height: 44px; - align-items: center; - gap: var(--sp-2); - padding: 10px; - border: 1px solid transparent; - border-bottom-width: 2px; - background: transparent; - color: rgba(244, 242, 237, 0.48); - cursor: pointer; - text-align: left; - transition: - color 180ms ease, - border-color 180ms ease, - background-color 180ms ease, - transform 160ms var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)); -} - -.notebook-tab:hover, -.notebook-tab[aria-selected="true"] { - border-bottom-color: var(--slash); - background: rgba(204, 255, 0, 0.06); - color: var(--white); -} - -.notebook-tab:focus-visible { - outline: 2px solid var(--slash); - outline-offset: -3px; -} - -.notebook-tab-number { - flex: 0 0 auto; - color: var(--slash); - font-family: var(--font-mono); - font-size: 10px; -} - -.notebook-tab-title { - overflow: hidden; - font-family: var(--font-display); - font-size: 12px; - font-weight: 500; - text-overflow: ellipsis; - white-space: nowrap; -} - -.notebook-panel { - display: grid; - grid-template-columns: 1fr; - align-items: start; - gap: var(--sp-6); - margin-top: var(--sp-6); -} - -.notebook-panel-copy { - order: 2; -} - -.notebook-panel-copy h3 { - color: var(--white); - font-family: var(--font-display); - font-size: clamp(22px, 3vw, 36px); - font-weight: 500; - letter-spacing: -0.04em; - line-height: 1; -} - -.notebook-panel-copy > p { - margin-top: var(--sp-2); - color: rgba(244, 242, 237, 0.6); - font-size: 12px; - line-height: 1.5; -} - -.notebook-panel-copy ul { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: var(--sp-4); - list-style: none; -} - -.notebook-panel-copy li { - padding: 4px 7px; - border: 1px solid rgba(244, 242, 237, 0.15); - color: rgba(244, 242, 237, 0.7); - font-family: var(--font-mono); - font-size: 9px; - line-height: 1.2; -} - -.notebook-panel-copy a { - display: inline-flex; - min-height: 44px; - align-items: center; - gap: 6px; - margin-top: var(--sp-5); - padding: 0 14px; - border: 1px solid var(--slash); - background: var(--slash); - color: var(--ink); - font-family: var(--font-mono); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - text-decoration: none; - text-transform: uppercase; - transition: - background-color 180ms ease, - border-color 180ms ease, - transform 180ms ease; -} - -.notebook-panel-copy a:hover { - border-color: var(--white); - background: var(--white); - color: var(--ink); -} - -.notebook-panel-copy a:active { - transform: scale(0.98); -} - -.notebook-panel-copy a:focus-visible { - outline: 2px solid var(--slash); - outline-offset: 3px; -} - -.notebook-screenshot-wrap { - position: relative; - overflow: hidden; - aspect-ratio: 8 / 5; - border: 1px solid rgba(244, 242, 237, 0.16); - background: var(--paper-dark); -} - -.notebook-screenshot-status { - position: absolute; - z-index: 2; - inset: auto 0 0; - padding: 10px 14px; - background: rgba(10, 10, 10, 0.92); - color: var(--paper); - font-size: 12px; - line-height: 1.4; -} - -.notebook-screenshot-wrap img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} -.notebook-screenshot { - position: absolute; - inset: 0; -} - -.notebook-screenshot-current { - z-index: 1; - animation: notebook-screenshot-in 220ms ease both; -} - -.notebook-screenshot-outgoing { - z-index: 0; -} - -.notebook-screenshot-pending { - opacity: 0; - pointer-events: none; -} - -@keyframes notebook-screenshot-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.notebook-cursor { - position: absolute; - top: 0; - left: 0; - z-index: 3; - width: 28px; - height: 36px; - overflow: visible; - opacity: 0; - pointer-events: none; - transform-origin: 1px 1px; - filter: drop-shadow(1px 2px 1px rgba(0, 0, 0, 0.4)); -} - -.notebook-cursor-pulse { - opacity: 0; -} - -.notebook-cursor.is-clicking .notebook-cursor-pulse { - opacity: 0.7; -} - -@media (max-width: 1024px) { - .notebook-story-runway { - min-height: var(--notebook-mobile-runway, 440svh); - } - - .notebook-story-sticky { - height: 100svh; - min-height: 100svh; - padding: 64px 20px 16px; - } - - .notebook-story-grid { - grid-template-columns: 1fr; - gap: 12px; - } - - .notebook-story-copy { - max-width: 560px; - } - - .notebook-story-copy h2 { - max-width: 12ch; - margin-top: 8px; - font-size: clamp(32px, 6vw, 44px); - } - - .notebook-story-copy p { - max-width: 44ch; - margin-top: 8px; - line-height: 1.45; - } - - .notebook-story-stage { - height: min(68vw, 38svh); - min-height: 0; - } - - .notebook-story-controls { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px 12px; - min-width: 0; - } - - .notebook-story-project-buttons { - display: flex; - grid-column: 1 / -1; - gap: 8px; - } - - .notebook-story-project-buttons button, - .notebook-story-controls > a { - display: inline-flex; - min-width: 44px; - min-height: 44px; - align-items: center; - justify-content: center; - padding: 8px 12px; - border: 1px solid rgba(244, 242, 237, 0.24); - color: var(--white); - font-family: var(--font-mono); - font-size: 11px; - text-decoration: none; - touch-action: manipulation; - } - - .notebook-story-project-buttons button[aria-pressed="true"] { - border-color: var(--slash); - color: var(--slash); - } - - .notebook-story-project-buttons button:focus-visible, - .notebook-story-controls > a:focus-visible { - outline: 2px solid var(--slash); - outline-offset: 3px; - } - - .notebook-story-controls > p { - min-width: 0; - font-size: 14px; - overflow-wrap: anywhere; - } - - .notebook-story-controls > span { - max-width: 18ch; - font-size: 10px; - } -} - -@media (max-width: 1024px) and (prefers-reduced-motion: no-preference) { - .notebook-story-pending { - min-height: var(--notebook-mobile-runway, 440svh); - } -} - -@media (max-width: 680px) { - .notebook-story-sticky { - padding-inline: 20px; - } - - .notebook-story-copy h2 { - font-size: clamp(32px, 8vw, 44px); - } - - .notebook-fallback { - padding-inline: 20px; - } - - .notebook-browser-body { - padding: 16px; - } - - .notebook-tablist { - grid-auto-flow: row; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .notebook-tab { - padding: 12px 8px; - } - - .notebook-tab-title { - white-space: normal; - } - - .notebook-screen-surface .notebook-tablist { - grid-auto-flow: column; - grid-template-columns: none; - } - - .notebook-screen-surface .notebook-tab-title { - white-space: nowrap; - } - - .notebook-panel { - grid-template-columns: 1fr; - } - - .notebook-panel-copy { - order: 2; - } -} - -@media (max-width: 1024px) and (max-height: 520px) and (orientation: landscape) { - .notebook-story-grid { - grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr); - } - - .notebook-story-copy h2 { - font-size: 28px; - } - - .notebook-story-copy p { - display: none; - } - - .notebook-story-stage { - height: 48svh; - } - - .notebook-story-controls { - grid-column: 1 / -1; - grid-template-columns: auto minmax(0, 1fr) auto; - } - - .notebook-story-project-buttons { - grid-column: auto; - } -} - -@media (prefers-reduced-motion: reduce) { - .notebook-tab, - .notebook-cursor, - .notebook-screenshot-current, - .notebook-screenshot-outgoing { - transition: none; - animation: none; - } -} - /* Interactive quality laboratory — scoped to the Lighthouse section. */ .quality-lab { --quality-muted: color-mix(in srgb, var(--paper) 72%, transparent);