diff --git a/app/components/story/NotebookBrowser.test.tsx b/app/components/story/NotebookBrowser.test.tsx new file mode 100644 index 0000000..8c93412 --- /dev/null +++ b/app/components/story/NotebookBrowser.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { portfolio } from "@/lib/portfolio-data"; +import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser"; +import { selectNotebookProjects } from "./notebookProjects"; + +const projects = selectNotebookProjects(portfolio.projects); +const frame: NotebookBrowserProps["frame"] = { + cursorVisible: true, + cursorX: 0.4, + cursorY: 0.6, + cursorClicking: false, +}; + +function renderBrowser(overrides: Partial = {}) { + return render( + , + ); +} + +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, + ); + }); + + 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("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("exposes a real deployment link with safe new-tab attributes", () => { + renderBrowser(); + + const link = screen.getByRole("link", { name: /abrir projeto/i }); + expect(link).toHaveAttribute("href", projects[0].demoUrl); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("keeps the animated cursor decorative and never embeds an iframe", () => { + const { container } = renderBrowser({ + frame: { ...frame, cursorClicking: true }, + }); + + expect(container.querySelector(".notebook-cursor")).toHaveAttribute( + "aria-hidden", + "true", + ); + expect(container.querySelector(".notebook-cursor")).toHaveStyle({ + left: "40%", + top: "60%", + }); + expect(container.querySelector("iframe")).not.toBeInTheDocument(); + }); + + it("uses the selected tab to label the panel and screenshot", () => { + renderBrowser({ activeIndex: 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).toHaveAttribute("src", "/assets/prints/print-sev.png"); + }); +}); diff --git a/app/components/story/NotebookBrowser.tsx b/app/components/story/NotebookBrowser.tsx new file mode 100644 index 0000000..2e560b3 --- /dev/null +++ b/app/components/story/NotebookBrowser.tsx @@ -0,0 +1,172 @@ +"use client"; + +import Image from "next/image"; +import type { NotebookProject } from "./notebookProjects"; +import type { NotebookFrame } from "./notebookTimeline"; + +export type NotebookBrowserProps = { + projects: NotebookProject[]; + activeIndex: number; + frame: Pick< + NotebookFrame, + "cursorVisible" | "cursorX" | "cursorY" | "cursorClicking" + >; + 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); +} + +function toDomId(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "-"); +} + +export default function NotebookBrowser({ + projects, + activeIndex, + frame, + onSelectTab, +}: NotebookBrowserProps) { + const selectedIndex = clampIndex(activeIndex, projects.length); + const selectedProject = + selectedIndex >= 0 ? projects[selectedIndex] : undefined; + + 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(); + 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")} + +
+ +
+ {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}
  • + ))} +
+ + Abrir projeto + +
+ +
+ {`Captura + {frame.cursorVisible ? ( +
+
+
+
+ ); +} diff --git a/app/components/story/NotebookFallback.test.tsx b/app/components/story/NotebookFallback.test.tsx new file mode 100644 index 0000000..2acdd7d --- /dev/null +++ b/app/components/story/NotebookFallback.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { portfolio } from "@/lib/portfolio-data"; +import NotebookFallback from "./NotebookFallback"; +import { selectNotebookProjects } from "./notebookProjects"; + +const projects = selectNotebookProjects(portfolio.projects); + +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 new file mode 100644 index 0000000..7d6bca3 --- /dev/null +++ b/app/components/story/NotebookFallback.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import NotebookBrowser from "./NotebookBrowser"; +import type { NotebookProject } from "./notebookProjects"; + +export type NotebookFallbackProps = { + projects: NotebookProject[]; + initialIndex?: number; +}; + +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, +}: 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. +

+
+ +
+ ); +} diff --git a/app/globals.css b/app/globals.css index 965c671..8af320e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -824,3 +824,386 @@ textarea { .cinematic-reveal { 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: 100dvh; +} + +.notebook-story-sticky { + position: sticky; + top: 0; + display: grid; + min-height: 100dvh; + place-items: center; + padding: clamp(40px, 7vw, 96px) clamp(20px, 6vw, 80px); +} + +.notebook-story-grid { + display: grid; + grid-template-columns: minmax(180px, 0.44fr) minmax(0, 1fr); + align-items: center; + gap: clamp(28px, 6vw, 96px); + width: min(1240px, 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; + min-height: min(62vw, 680px); + place-items: center; +} + +.notebook-story-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.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-template-columns: repeat(4, 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; + align-items: center; + gap: var(--sp-2); + padding: 14px 10px; + border: 0; + border-bottom: 2px solid transparent; + 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; +} + +.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-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: minmax(140px, 0.35fr) minmax(0, 1fr); + align-items: center; + gap: clamp(20px, 3vw, 36px); + margin-top: var(--sp-6); +} + +.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; + align-items: center; + gap: 6px; + margin-top: var(--sp-5); + color: var(--slash); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.notebook-panel-copy a:hover { + color: var(--white); +} + +.notebook-screenshot-wrap { + position: relative; + overflow: hidden; + aspect-ratio: 16 / 9; + border: 1px solid rgba(244, 242, 237, 0.16); + background: var(--paper-dark); +} + +.notebook-screenshot-wrap img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.notebook-cursor { + position: absolute; + z-index: 1; + width: 15px; + height: 20px; + border: 2px solid var(--white); + background: var(--ink); + box-shadow: 2px 2px 0 var(--slash); + pointer-events: none; + transform: translate(-2px, -2px) rotate(-18deg); + transition: + width 120ms ease, + height 120ms ease, + box-shadow 120ms ease; +} + +.notebook-cursor.is-clicking { + width: 12px; + height: 16px; + box-shadow: 1px 1px 0 var(--slash); +} + +@media (max-width: 1024px) { + .notebook-story-runway { + min-height: auto; + } + + .notebook-story-sticky { + position: relative; + min-height: auto; + } + + .notebook-story-grid { + grid-template-columns: 1fr; + } + + .notebook-story-copy { + max-width: 560px; + } + + .notebook-story-copy h2 { + max-width: 12ch; + } + + .notebook-story-stage { + min-height: min(78vw, 620px); + } +} + +@media (max-width: 680px) { + .notebook-story-sticky { + padding-inline: 20px; + } + + .notebook-story-copy h2 { + font-size: clamp(38px, 14vw, 64px); + } + + .notebook-story-stage { + min-height: 84vw; + } + + .notebook-fallback { + padding-inline: 20px; + } + + .notebook-browser-body { + padding: 16px; + } + + .notebook-tablist { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .notebook-tab { + padding: 12px 8px; + } + + .notebook-tab-title { + white-space: normal; + } + + .notebook-panel { + grid-template-columns: 1fr; + } + + .notebook-panel-copy { + order: 2; + } +} + +@media (prefers-reduced-motion: reduce) { + .notebook-tab, + .notebook-cursor { + transition: none; + } +}