feat: definir contratos do notebook story

This commit is contained in:
Evandro Fonseca Junior committed 2026-08-30 23:35:44 -03:00
1 parent d89dde21ee
commit 8b858528f8
4 files changed
+337

No files matched your search

@@ -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");
});
});
+52
View File
@@ -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,
},
];
});
}
@@ -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,
});
});
});
+145
View File
@@ -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),
};
}