import type { SectionId } from "@/content/types"; import type { JourneySource } from "./types"; declare const __PORTFOLIO_TEST_BUILD__: boolean; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== "undefined" && __PORTFOLIO_TEST_BUILD__; export interface NavigationState { epoch: number; source: JourneySource; destinationId: SectionId | null; } export interface NavigationRuntime { getHash(): string; pushHash(hash: string): void; replaceHash(hash: string): void; scrollTarget(target: HTMLElement, behavior: ScrollBehavior): void; focusHeading(target: HTMLElement): void; getScrollY(): number; requestFrame(callback: FrameRequestCallback): number; cancelFrame(id: number): void; setTimer(callback: () => void, delay: number): number; clearTimer(id: number): void; supportsScrollEnd: boolean; addScrollEndListener(listener: EventListener): void; removeScrollEndListener(listener: EventListener): void; } export interface NavigationController { navigate(id: SectionId, target: HTMLElement): number; noteUserScrollIntent(): number; notePassiveSection(id: SectionId): void; initialize(id: SectionId, target: HTMLElement): number; restore(id: SectionId, target: HTMLElement): number; state(): NavigationState; subscribe(listener: (state: NavigationState) => void): () => void; dispose(): void; } const SETTLE_TIMEOUT_MS = 1500; const PASSIVE_REPLACE_DELAY_MS = 150; const STABLE_FRAME_COUNT = 3; const STABLE_SCROLL_DELTA_PX = 1; export function canonicalSectionIdFromHash( hash: string, canonicalIds: readonly SectionId[], ): SectionId | null { const encoded = hash.startsWith("#") ? hash.slice(1) : hash; if (encoded === "") return canonicalIds[0] ?? null; let decoded: string; try { decoded = decodeURIComponent(encoded); } catch { return null; } return canonicalIds.find((id) => id === decoded) ?? null; } export function validateSectionAnchor( anchor: HTMLAnchorElement, canonicalIds: readonly SectionId[], ): { id: SectionId; target: HTMLElement } | null { let url: URL; try { url = new URL(anchor.href, window.location.href); } catch { return null; } if ( url.origin !== window.location.origin || url.pathname !== window.location.pathname || url.search !== window.location.search ) { return null; } const id = canonicalSectionIdFromHash(url.hash, canonicalIds); if (id === null) return null; const target = document.getElementById(id); if (!(target instanceof HTMLElement) || target.id !== id) return null; const heading = target.querySelector( 'h1[tabindex="-1"], h2[tabindex="-1"]', ); return heading === null ? null : { id, target }; } const browserRuntime = (): NavigationRuntime => ({ getHash: () => window.location.hash, pushHash: (hash) => window.history.pushState(window.history.state, "", hash), replaceHash: (hash) => window.history.replaceState(window.history.state, "", hash), scrollTarget: (target, behavior) => target.scrollIntoView({ behavior, block: "start" }), focusHeading: (target) => { target .querySelector('h1[tabindex="-1"], h2[tabindex="-1"]') ?.focus({ preventScroll: true }); }, getScrollY: () => window.scrollY, requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (id) => window.cancelAnimationFrame(id), setTimer: (callback, delay) => window.setTimeout(callback, delay), clearTimer: (id) => window.clearTimeout(id), supportsScrollEnd: "onscrollend" in window, addScrollEndListener: (listener) => window.addEventListener("scrollend", listener), removeScrollEndListener: (listener) => window.removeEventListener("scrollend", listener), }); export function createNavigationController( canonicalIds: readonly SectionId[], runtime: NavigationRuntime = browserRuntime(), ): NavigationController { let currentState: NavigationState = { epoch: 0, source: "initial", destinationId: null, }; const subscribers = new Set<(state: NavigationState) => void>(); let frameId: number | undefined; let settleTimerId: number | undefined; let passiveTimerId: number | undefined; let scrollEndListener: EventListener | undefined; const emit = (): void => { for (const subscriber of subscribers) subscriber({ ...currentState }); }; const cancelSettle = (): void => { if (frameId !== undefined) { runtime.cancelFrame(frameId); frameId = undefined; } if (settleTimerId !== undefined) { runtime.clearTimer(settleTimerId); settleTimerId = undefined; } if (scrollEndListener !== undefined) { runtime.removeScrollEndListener(scrollEndListener); scrollEndListener = undefined; } }; const cancelPassiveReplace = (): void => { if (passiveTimerId !== undefined) { runtime.clearTimer(passiveTimerId); passiveTimerId = undefined; } }; const cancelPending = (): void => { cancelSettle(); cancelPassiveReplace(); }; const beginEpoch = ( source: JourneySource, destinationId: SectionId | null, ): number => { cancelPending(); currentState = { epoch: currentState.epoch + 1, source, destinationId, }; if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch); if ( TEST_BUILD && source === "explicit-navigation" && (destinationId === "toolkit" || destinationId === "contact") ) { window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(destinationId); } emit(); return currentState.epoch; }; const requireTarget = (id: SectionId, target: HTMLElement): void => { if (!canonicalIds.includes(id) || target.id !== id) { throw new Error(`Invalid canonical navigation target: ${id}`); } }; const settleExplicit = ( epoch: number, id: SectionId, target: HTMLElement, ): void => { if ( currentState.epoch !== epoch || currentState.source !== "explicit-navigation" || currentState.destinationId !== id ) { return; } if ( TEST_BUILD && window.__PORTFOLIO_PERFORMANCE__?.holdNavigationSettlement(epoch) ) return; cancelSettle(); frameId = runtime.requestFrame(() => { frameId = undefined; if ( currentState.epoch === epoch && currentState.source === "explicit-navigation" && currentState.destinationId === id ) { runtime.focusHeading(target); } }); }; const scheduleSettle = ( epoch: number, id: SectionId, target: HTMLElement, ): void => { if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.registerNavigationSettlement(epoch); settleTimerId = runtime.setTimer(() => { settleTimerId = undefined; if ( currentState.epoch !== epoch || currentState.source !== "explicit-navigation" || currentState.destinationId !== id ) { return; } runtime.scrollTarget(target, "instant"); settleExplicit(epoch, id, target); }, SETTLE_TIMEOUT_MS); if (runtime.supportsScrollEnd) { scrollEndListener = (): void => settleExplicit(epoch, id, target); runtime.addScrollEndListener(scrollEndListener); return; } let previousScrollY = runtime.getScrollY(); let stableFrames = 0; const inspectFrame = (): void => { frameId = undefined; if ( currentState.epoch !== epoch || currentState.source !== "explicit-navigation" || currentState.destinationId !== id ) { return; } const scrollY = runtime.getScrollY(); stableFrames = Math.abs(scrollY - previousScrollY) < STABLE_SCROLL_DELTA_PX ? stableFrames + 1 : 0; previousScrollY = scrollY; if (stableFrames >= STABLE_FRAME_COUNT) { settleExplicit(epoch, id, target); } else { frameId = runtime.requestFrame(inspectFrame); } }; frameId = runtime.requestFrame(inspectFrame); }; const alignWithoutFocus = ( source: "initial" | "history-restoration", id: SectionId, target: HTMLElement, ): number => { requireTarget(id, target); const epoch = beginEpoch(source, id); if (source === "initial") { runtime.scrollTarget(target, "instant"); } else { frameId = runtime.requestFrame(() => { frameId = undefined; if ( currentState.epoch === epoch && currentState.source === source && currentState.destinationId === id ) { runtime.scrollTarget(target, "instant"); } }); } return epoch; }; return { navigate(id, target) { requireTarget(id, target); const epoch = beginEpoch("explicit-navigation", id); if (canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id) { runtime.pushHash(`#${id}`); } scheduleSettle(epoch, id, target); runtime.scrollTarget(target, "smooth"); return epoch; }, noteUserScrollIntent() { return beginEpoch("passive-scroll", null); }, notePassiveSection(id) { if (currentState.source !== "passive-scroll") return; cancelPassiveReplace(); const epoch = currentState.epoch; passiveTimerId = runtime.setTimer(() => { passiveTimerId = undefined; if ( currentState.epoch === epoch && currentState.source === "passive-scroll" && canonicalSectionIdFromHash(runtime.getHash(), canonicalIds) !== id ) { runtime.replaceHash(`#${id}`); } }, PASSIVE_REPLACE_DELAY_MS); }, initialize(id, target) { return alignWithoutFocus("initial", id, target); }, restore(id, target) { return alignWithoutFocus("history-restoration", id, target); }, state: () => ({ ...currentState }), subscribe(listener) { subscribers.add(listener); listener({ ...currentState }); return () => subscribers.delete(listener); }, dispose() { cancelPending(); currentState = { ...currentState, epoch: currentState.epoch + 1 }; subscribers.clear(); }, }; }