From 8b858528f84097f34404cddf2f49ea013749021c Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sun, 30 Aug 2026 23:35:44 -0300 Subject: [PATCH] feat: definir contratos do notebook story --- app/components/story/notebookProjects.test.ts | 51 ++++++ app/components/story/notebookProjects.ts | 52 +++++++ app/components/story/notebookTimeline.test.ts | 89 +++++++++++ app/components/story/notebookTimeline.ts | 145 ++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 app/components/story/notebookProjects.test.ts create mode 100644 app/components/story/notebookProjects.ts create mode 100644 app/components/story/notebookTimeline.test.ts create mode 100644 app/components/story/notebookTimeline.ts diff --git a/app/components/story/notebookProjects.test.ts b/app/components/story/notebookProjects.test.ts new file mode 100644 index 0000000..c7e460e --- /dev/null +++ b/app/components/story/notebookProjects.test.ts @@ -0,0 +1,51 @@ +import { portfolio, type PortfolioProject } from "@/lib/portfolio-data"; +import { selectNotebookProjects } from "./notebookProjects"; + +const projectsFixture = portfolio.projects as PortfolioProject[]; + +describe("selectNotebookProjects", () => { + it("returns the four ordered projects with local screenshots", () => { + const result = selectNotebookProjects(projectsFixture); + + expect(result.map((project) => project.id)).toEqual([ + "nutriscan", + "sc-plus", + "sev-modaintima", + "as-servicos", + ]); + expect(result.every((project) => project.screenshotSrc.startsWith("/assets/prints/"))).toBe(true); + }); + + 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 without an approved demo URL", () => { + const result = selectNotebookProjects( + projectsFixture.map((project) => + project.id === "nutriscan" ? { ...project, demoUrl: null } : project, + ), + ); + + expect(result.some((project) => project.id === "nutriscan")).toBe(false); + }); + + it("copies only the browser contract fields", () => { + const [project] = selectNotebookProjects(projectsFixture); + + expect(project).toEqual({ + id: "nutriscan", + title: "NutriScan", + subtitle: "AnĂ¡lise Nutricional Inteligente", + demoUrl: "https://nutriscan.evandro.dev.br", + technologies: expect.arrayContaining(["Next.js 14", "TypeScript"]), + screenshotSrc: "/assets/prints/print-nutriscan.png", + }); + expect(project).not.toHaveProperty("description"); + }); +}); diff --git a/app/components/story/notebookProjects.ts b/app/components/story/notebookProjects.ts new file mode 100644 index 0000000..00c157e --- /dev/null +++ b/app/components/story/notebookProjects.ts @@ -0,0 +1,52 @@ +import type { PortfolioProject } from "@/lib/portfolio-data"; + +export type NotebookProject = { + id: string; + title: string; + subtitle: string; + demoUrl: string; + technologies: string[]; + screenshotSrc: string; +}; + +const NOTEBOOK_ORDER = [ + "nutriscan", + "sc-plus", + "sev-modaintima", + "as-servicos", +] as const; + +const SCREENSHOT_BY_PROJECT_ID: Record<(typeof NOTEBOOK_ORDER)[number], string> = { + nutriscan: "/assets/prints/print-nutriscan.png", + "sc-plus": "/assets/prints/print-scplus.png", + "sev-modaintima": "/assets/prints/print-sev.png", + "as-servicos": "/assets/prints/print-asservicos.png", +}; + +function isApprovedDemoUrl(value: PortfolioProject["demoUrl"]): value is string { + return typeof value === "string" && /^https?:\/\//.test(value); +} + +export function selectNotebookProjects( + projects: readonly PortfolioProject[], +): NotebookProject[] { + return NOTEBOOK_ORDER.flatMap((id) => { + const project = projects.find((candidate) => candidate.id === id); + const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id]; + + if (!project || !isApprovedDemoUrl(project.demoUrl)) { + return []; + } + + return [ + { + id: project.id, + title: project.title, + subtitle: project.subtitle, + demoUrl: project.demoUrl, + technologies: [...project.technologies], + screenshotSrc, + }, + ]; + }); +} diff --git a/app/components/story/notebookTimeline.test.ts b/app/components/story/notebookTimeline.test.ts new file mode 100644 index 0000000..48c0995 --- /dev/null +++ b/app/components/story/notebookTimeline.test.ts @@ -0,0 +1,89 @@ +import { getNotebookFrame } from "./notebookTimeline"; + +describe("getNotebookFrame", () => { + it("clamps progress below zero and starts with a closed device", () => { + expect(getNotebookFrame(-1, 4)).toMatchObject({ + phase: "approach", + lidAngle: 0, + activeTabIndex: -1, + cursorVisible: false, + }); + }); + + it("clamps progress above one and ends with a closed device", () => { + expect(getNotebookFrame(2, 4)).toMatchObject({ + phase: "close", + lidAngle: 0, + activeTabIndex: -1, + cursorVisible: false, + }); + }); + + it("keeps the phase boundaries deterministic", () => { + expect(getNotebookFrame(0.1, 4).phase).toBe("open"); + expect(getNotebookFrame(0.25, 4).phase).toBe("boot"); + expect(getNotebookFrame(0.34, 4).phase).toBe("cursor-in"); + expect(getNotebookFrame(0.44, 4).phase).toBe("browse"); + expect(getNotebookFrame(0.78, 4).phase).toBe("hold"); + expect(getNotebookFrame(0.87, 4).phase).toBe("close"); + }); + + it("opens the lid before the browser starts", () => { + const frame = getNotebookFrame(0.22, 4); + + expect(frame.phase).toBe("open"); + expect(frame.lidAngle).toBeGreaterThan(0); + expect(frame.screenOpacity).toBe(0); + }); + + it("boots the screen before the cursor enters", () => { + expect(getNotebookFrame(0.25, 4).screenOpacity).toBe(0); + expect(getNotebookFrame(0.34, 4).screenOpacity).toBe(1); + expect(getNotebookFrame(0.4, 4).cursorVisible).toBe(true); + }); + + it("selects each tab only inside the browse window", () => { + expect(getNotebookFrame(0.43, 4).activeTabIndex).toBe(-1); + expect(getNotebookFrame(0.48, 4).activeTabIndex).toBe(0); + expect(getNotebookFrame(0.57, 4).activeTabIndex).toBe(1); + expect(getNotebookFrame(0.67, 4).activeTabIndex).toBe(2); + expect(getNotebookFrame(0.76, 4).activeTabIndex).toBe(3); + }); + + it("holds the final tab until the closing phase", () => { + expect(getNotebookFrame(0.84, 4)).toMatchObject({ + phase: "hold", + activeTabIndex: 3, + cursorVisible: true, + }); + expect(getNotebookFrame(0.88, 4)).toMatchObject({ + phase: "close", + activeTabIndex: -1, + cursorVisible: false, + }); + }); + + it("emits a deterministic cursor click pulse for each tab", () => { + const first = getNotebookFrame(0.447, 4); + const later = getNotebookFrame(0.53, 4); + + expect(first.cursorClicking).toBe(true); + expect(later.cursorClicking).toBe(true); + expect(first.cursorX).toBeGreaterThanOrEqual(0); + expect(first.cursorX).toBeLessThanOrEqual(1); + expect(first.cursorY).toBeGreaterThanOrEqual(0); + expect(first.cursorY).toBeLessThanOrEqual(1); + }); + + it("is deterministic when rewound to the same progress", () => { + expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4)); + }); + + it("does not expose an invalid tab when there are no projects", () => { + expect(getNotebookFrame(0.6, 0)).toMatchObject({ + activeTabIndex: -1, + cursorVisible: false, + cursorClicking: false, + }); + }); +}); diff --git a/app/components/story/notebookTimeline.ts b/app/components/story/notebookTimeline.ts new file mode 100644 index 0000000..cb63efb --- /dev/null +++ b/app/components/story/notebookTimeline.ts @@ -0,0 +1,145 @@ +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_ANGLE = 112; +const BROWSE_START = 0.44; +const BROWSE_END = 0.78; +const HOLD_END = 0.87; +const CURSOR_POINTS = [ + { x: 0.26, y: 0.68 }, + { x: 0.58, y: 0.32 }, + { x: 0.74, y: 0.72 }, + { x: 0.38, y: 0.28 }, +]; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function normalizeProgress(progress: number): number { + return clamp(Number.isFinite(progress) ? progress : 0, 0, 1); +} + +function interpolate(from: number, to: number, progress: number): number { + return from + (to - from) * clamp(progress, 0, 1); +} + +function getPhase(progress: number): NotebookPhase { + if (progress < 0.1) return "approach"; + if (progress < 0.25) return "open"; + if (progress < 0.34) return "boot"; + if (progress < BROWSE_START) return "cursor-in"; + if (progress < BROWSE_END) return "browse"; + if (progress < HOLD_END) return "hold"; + return "close"; +} + +function getActiveTabIndex(progress: number, tabCount: number, phase: NotebookPhase): number { + if (tabCount < 1 || phase === "approach" || phase === "open" || phase === "boot" || phase === "cursor-in" || phase === "close") { + return -1; + } + + if (phase === "hold") { + return tabCount - 1; + } + + const browseProgress = (progress - BROWSE_START) / (BROWSE_END - BROWSE_START); + return clamp(Math.floor(browseProgress * tabCount), 0, tabCount - 1); +} + +function getCursorPoint(progress: number, phase: NotebookPhase, activeTabIndex: number): { x: number; y: number } { + if (phase === "cursor-in") { + const cursorProgress = (progress - 0.34) / 0.1; + return { + x: interpolate(0.06, 0.26, cursorProgress), + y: interpolate(0.88, 0.68, cursorProgress), + }; + } + + if (activeTabIndex < 0) { + return { x: 0.06, y: 0.88 }; + } + + const point = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length]; + return point; +} + +function isCursorClicking(progress: number, phase: NotebookPhase, tabCount: number): boolean { + if (tabCount < 1 || phase === "approach" || phase === "open" || phase === "boot" || phase === "close") { + return false; + } + + if (phase === "cursor-in") { + return progress >= 0.43; + } + + if (phase === "hold") { + return false; + } + + const browseProgress = (progress - BROWSE_START) / (BROWSE_END - BROWSE_START); + const slotProgress = (browseProgress * tabCount) % 1; + return slotProgress < 0.12; +} + +export function getNotebookFrame(progress: number, tabCount: number): NotebookFrame { + const normalizedProgress = normalizeProgress(progress); + const normalizedTabCount = Math.max(0, Math.floor(tabCount)); + const phase = getPhase(normalizedProgress); + const activeTabIndex = getActiveTabIndex(normalizedProgress, normalizedTabCount, phase); + const isApproach = normalizedProgress < 0.1; + const isOpening = phase === "open"; + const isBooting = phase === "boot"; + const isClosing = phase === "close"; + const lidAngle = isOpening + ? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15) + : isClosing + ? interpolate(OPEN_ANGLE, 0, (normalizedProgress - HOLD_END) / (1 - HOLD_END)) + : normalizedProgress >= 0.25 + ? OPEN_ANGLE + : 0; + const screenOpacity = isBooting + ? interpolate(0, 1, (normalizedProgress - 0.25) / 0.09) + : normalizedProgress >= 0.34 && !isClosing + ? 1 + : isClosing + ? interpolate(1, 0, (normalizedProgress - HOLD_END) / (1 - HOLD_END)) + : 0; + const deviceOpacity = isApproach + ? interpolate(0, 1, normalizedProgress / 0.1) + : isClosing + ? interpolate(1, 0.92, (normalizedProgress - HOLD_END) / (1 - HOLD_END)) + : 1; + const cursorVisible = (phase === "cursor-in" || phase === "browse" || phase === "hold") && normalizedTabCount > 0; + const { x: cursorX, y: cursorY } = getCursorPoint(normalizedProgress, phase, activeTabIndex); + + return { + phase, + lidAngle, + deviceOpacity, + screenOpacity, + activeTabIndex, + cursorVisible, + cursorX, + cursorY, + cursorClicking: cursorVisible && isCursorClicking(normalizedProgress, phase, normalizedTabCount), + }; +}