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