feat: definir contratos do notebook story
This commit is contained in:
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user