feat: coordinate sections signs and native navigation
This commit is contained in:
@@ -1,43 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SectionId } from '@/content/types';
|
import { watch } from "vue";
|
||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
import { useJourneySections } from "@/composables/useJourneySections";
|
||||||
|
import { useKeyboardNavigation } from "@/composables/useKeyboardNavigation";
|
||||||
|
|
||||||
defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
|
const props = defineProps<{
|
||||||
|
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||||
|
}>();
|
||||||
|
const canonicalIds = props.items.map((item) => item.id);
|
||||||
|
const navigation = useKeyboardNavigation(canonicalIds);
|
||||||
|
const journey = useJourneySections(canonicalIds, navigation.state);
|
||||||
|
|
||||||
const focusActivatedSection = (event: MouseEvent): void => {
|
watch(
|
||||||
if (
|
() => journey.value.activeSectionId,
|
||||||
event.defaultPrevented ||
|
(id) => navigation.notePassiveSection(id),
|
||||||
event.button !== 0 ||
|
);
|
||||||
event.altKey ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
!(event.currentTarget instanceof HTMLAnchorElement)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sectionId: string;
|
|
||||||
try {
|
|
||||||
sectionId = decodeURIComponent(event.currentTarget.hash.slice(1));
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const section = document.getElementById(sectionId);
|
|
||||||
const heading = section?.querySelector<HTMLElement>(
|
|
||||||
':scope > h2[tabindex="-1"]',
|
|
||||||
);
|
|
||||||
if (heading === undefined || heading === null) return;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => heading.focus({ preventScroll: true }));
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="site-navigation" aria-label="Portfolio sections">
|
<nav class="site-navigation" aria-label="Portfolio sections">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="item in items" :key="item.id">
|
<li v-for="item in items" :key="item.id">
|
||||||
<a :href="`#${item.id}`" @click="focusActivatedSection">{{ item.label }}</a>
|
<a
|
||||||
|
:href="`#${item.id}`"
|
||||||
|
:aria-current="
|
||||||
|
journey.activeSectionId === item.id ? 'page' : undefined
|
||||||
|
"
|
||||||
|
@click="navigation.onSectionLinkClick"
|
||||||
|
>{{ item.label }}</a
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
243
src/composables/useJourneySections.ts
Normal file
243
src/composables/useJourneySections.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import {
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
readonly,
|
||||||
|
shallowRef,
|
||||||
|
watch,
|
||||||
|
type Ref,
|
||||||
|
} from "vue";
|
||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
import type { NavigationState } from "@/journey/navigation-controller";
|
||||||
|
import {
|
||||||
|
activationLineDocumentY,
|
||||||
|
deriveSectionState,
|
||||||
|
isAtDocumentEnd,
|
||||||
|
selectActiveSection,
|
||||||
|
type SectionHeadingGeometry,
|
||||||
|
} from "@/journey/section-controller";
|
||||||
|
import { nextSign } from "@/journey/sign-controller";
|
||||||
|
import type { JourneySnapshot } from "@/journey/types";
|
||||||
|
import { useScrollDrive } from "./useScrollDrive";
|
||||||
|
|
||||||
|
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
||||||
|
|
||||||
|
const initialJourney = (activeSectionId: SectionId): JourneySnapshot => ({
|
||||||
|
scrollY: 0,
|
||||||
|
routeProgress: 0,
|
||||||
|
direction: 0,
|
||||||
|
acceleration: 0,
|
||||||
|
driveSpeed: 0.18,
|
||||||
|
...deriveSectionState(activeSectionId),
|
||||||
|
sign: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useJourneySections(
|
||||||
|
canonicalIds: readonly SectionId[],
|
||||||
|
navigationState: Readonly<Ref<NavigationState>>,
|
||||||
|
motionAllowed?: Readonly<Ref<boolean>>,
|
||||||
|
): Readonly<Ref<JourneySnapshot>> {
|
||||||
|
const firstId = canonicalIds[0];
|
||||||
|
if (firstId === undefined) {
|
||||||
|
throw new Error("At least one canonical journey section is required.");
|
||||||
|
}
|
||||||
|
const drive = useScrollDrive();
|
||||||
|
const snapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
|
||||||
|
let scheduleMeasurement = (): void => {};
|
||||||
|
let teardown = (): void => {};
|
||||||
|
|
||||||
|
const stopNavigationWatch = watch(
|
||||||
|
() => navigationState.value,
|
||||||
|
() => scheduleMeasurement(),
|
||||||
|
);
|
||||||
|
const stopMotionWatch =
|
||||||
|
motionAllowed === undefined
|
||||||
|
? (): void => {}
|
||||||
|
: watch(motionAllowed, () => scheduleMeasurement());
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
let frameId: number | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
let previousScrollY = window.scrollY;
|
||||||
|
let previousActiveId = firstId;
|
||||||
|
let headings: readonly SectionHeadingGeometry[] = [];
|
||||||
|
|
||||||
|
const readHeadings = (): readonly SectionHeadingGeometry[] =>
|
||||||
|
canonicalIds.flatMap((id) => {
|
||||||
|
const heading = document.getElementById(`${id}-heading`);
|
||||||
|
return heading instanceof HTMLElement
|
||||||
|
? [{ id, top: heading.getBoundingClientRect().top + window.scrollY }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const measure = (): void => {
|
||||||
|
frameId = undefined;
|
||||||
|
headings = readHeadings();
|
||||||
|
if (headings.length === 0) return;
|
||||||
|
|
||||||
|
const scrollingElement =
|
||||||
|
document.scrollingElement ?? document.documentElement;
|
||||||
|
const viewportHeight =
|
||||||
|
visualViewport?.height ?? document.documentElement.clientHeight;
|
||||||
|
const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
|
||||||
|
const scrollY = scrollingElement.scrollTop;
|
||||||
|
const activationY = activationLineDocumentY({
|
||||||
|
scrollY,
|
||||||
|
visualViewportOffsetTop: viewportOffsetTop,
|
||||||
|
visualViewportHeight: viewportHeight,
|
||||||
|
});
|
||||||
|
const atDocumentEnd = isAtDocumentEnd({
|
||||||
|
scrollY,
|
||||||
|
scrollHeight: scrollingElement.scrollHeight,
|
||||||
|
visualViewportHeight: viewportHeight,
|
||||||
|
});
|
||||||
|
const activeSectionId = selectActiveSection({
|
||||||
|
headings,
|
||||||
|
activationY,
|
||||||
|
atDocumentEnd,
|
||||||
|
});
|
||||||
|
const previousActiveIndex = canonicalIds.indexOf(previousActiveId);
|
||||||
|
const activeIndex = canonicalIds.indexOf(activeSectionId);
|
||||||
|
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
|
||||||
|
const deltaY = scrollY - previousScrollY;
|
||||||
|
const currentSign = snapshot.value.sign;
|
||||||
|
const signTargetId =
|
||||||
|
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
|
||||||
|
const signTarget = headings.find(
|
||||||
|
(heading) => heading.id === signTargetId,
|
||||||
|
);
|
||||||
|
const sign =
|
||||||
|
signTargetId === null || signTarget === undefined || viewportHeight <= 0
|
||||||
|
? null
|
||||||
|
: nextSign(currentSign, {
|
||||||
|
source: navigationState.value.source,
|
||||||
|
deltaY,
|
||||||
|
viewportHeight,
|
||||||
|
crossedBoundaries,
|
||||||
|
candidateId: signTargetId,
|
||||||
|
distanceVh: (signTarget.top - activationY) / viewportHeight,
|
||||||
|
motionAllowed: motionAllowed?.value ?? true,
|
||||||
|
});
|
||||||
|
const sectionState = deriveSectionState(activeSectionId);
|
||||||
|
snapshot.value = {
|
||||||
|
...drive.value,
|
||||||
|
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
|
||||||
|
...sectionState,
|
||||||
|
sign,
|
||||||
|
};
|
||||||
|
previousScrollY = scrollY;
|
||||||
|
previousActiveId = activeSectionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleMeasurement = (): void => {
|
||||||
|
if (
|
||||||
|
!disposed &&
|
||||||
|
frameId === undefined &&
|
||||||
|
document.visibilityState !== "hidden"
|
||||||
|
) {
|
||||||
|
frameId = window.requestAnimationFrame(measure);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(scheduleMeasurement);
|
||||||
|
resizeObserver.observe(document.documentElement);
|
||||||
|
for (const id of canonicalIds) {
|
||||||
|
const section = document.getElementById(id);
|
||||||
|
const heading = document.getElementById(`${id}-heading`);
|
||||||
|
if (section !== null) resizeObserver.observe(section);
|
||||||
|
if (heading !== null) resizeObserver.observe(heading);
|
||||||
|
}
|
||||||
|
for (const media of document.querySelectorAll("img, video")) {
|
||||||
|
resizeObserver.observe(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVisibility = (): void => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
if (frameId !== undefined) {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
frameId = undefined;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previousScrollY = window.scrollY;
|
||||||
|
scheduleMeasurement();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", scheduleMeasurement, PASSIVE_LISTENER);
|
||||||
|
window.addEventListener("resize", scheduleMeasurement, PASSIVE_LISTENER);
|
||||||
|
window.addEventListener(
|
||||||
|
"orientationchange",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
visualViewport?.addEventListener(
|
||||||
|
"resize",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
visualViewport?.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
document.addEventListener("load", scheduleMeasurement, true);
|
||||||
|
document.addEventListener("loadedmetadata", scheduleMeasurement, true);
|
||||||
|
document.addEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
handleVisibility,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleMeasurement();
|
||||||
|
void document.fonts.ready.then(() => scheduleMeasurement());
|
||||||
|
|
||||||
|
teardown = (): void => {
|
||||||
|
disposed = true;
|
||||||
|
window.removeEventListener(
|
||||||
|
"scroll",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
"resize",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
"orientationchange",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
visualViewport?.removeEventListener(
|
||||||
|
"resize",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
visualViewport?.removeEventListener(
|
||||||
|
"scroll",
|
||||||
|
scheduleMeasurement,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
document.removeEventListener("load", scheduleMeasurement, true);
|
||||||
|
document.removeEventListener("loadedmetadata", scheduleMeasurement, true);
|
||||||
|
document.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
handleVisibility,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (frameId !== undefined) {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
frameId = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopNavigationWatch();
|
||||||
|
stopMotionWatch();
|
||||||
|
teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
return readonly(snapshot);
|
||||||
|
}
|
||||||
142
src/composables/useKeyboardNavigation.ts
Normal file
142
src/composables/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from "vue";
|
||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
import {
|
||||||
|
canonicalSectionIdFromHash,
|
||||||
|
createNavigationController,
|
||||||
|
validateSectionAnchor,
|
||||||
|
type NavigationController,
|
||||||
|
type NavigationState,
|
||||||
|
} from "@/journey/navigation-controller";
|
||||||
|
|
||||||
|
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
||||||
|
const SCROLL_KEYS = new Set([
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
" ",
|
||||||
|
"Spacebar",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const isScrollIntentKey = (key: string): boolean => SCROLL_KEYS.has(key);
|
||||||
|
|
||||||
|
const isEditableTarget = (target: EventTarget | null): boolean =>
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.isContentEditable ||
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement);
|
||||||
|
|
||||||
|
export interface KeyboardNavigation {
|
||||||
|
state: Readonly<Ref<NavigationState>>;
|
||||||
|
onSectionLinkClick(event: MouseEvent): void;
|
||||||
|
notePassiveSection(id: SectionId): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardNavigation(
|
||||||
|
canonicalIds: readonly SectionId[],
|
||||||
|
): KeyboardNavigation {
|
||||||
|
const state = shallowRef<NavigationState>({
|
||||||
|
epoch: 0,
|
||||||
|
source: "initial",
|
||||||
|
destinationId: null,
|
||||||
|
});
|
||||||
|
let controller: NavigationController | undefined;
|
||||||
|
let teardown = (): void => {};
|
||||||
|
|
||||||
|
const restoreCurrentFragment = (
|
||||||
|
source: "initial" | "history-restoration",
|
||||||
|
): void => {
|
||||||
|
if (controller === undefined) return;
|
||||||
|
const id = canonicalSectionIdFromHash(window.location.hash, canonicalIds);
|
||||||
|
if (id === null) return;
|
||||||
|
const target = document.getElementById(id);
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
if (source === "initial") controller.initialize(id, target);
|
||||||
|
else controller.restore(id, target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSectionLinkClick = (event: MouseEvent): void => {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.altKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
controller === undefined ||
|
||||||
|
!(event.currentTarget instanceof HTMLAnchorElement)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validated = validateSectionAnchor(event.currentTarget, canonicalIds);
|
||||||
|
if (validated === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
controller.navigate(validated.id, validated.target);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
controller = createNavigationController(canonicalIds);
|
||||||
|
const unsubscribe = controller.subscribe((nextState) => {
|
||||||
|
state.value = nextState;
|
||||||
|
});
|
||||||
|
const noteScrollIntent = (): void => {
|
||||||
|
controller?.noteUserScrollIntent();
|
||||||
|
};
|
||||||
|
const handleKeydown = (event: KeyboardEvent): void => {
|
||||||
|
if (
|
||||||
|
event.altKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
isEditableTarget(event.target) ||
|
||||||
|
!isScrollIntentKey(event.key)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noteScrollIntent();
|
||||||
|
};
|
||||||
|
const handlePopstate = (): void =>
|
||||||
|
restoreCurrentFragment("history-restoration");
|
||||||
|
const handlePageshow = (event: PageTransitionEvent): void => {
|
||||||
|
if (event.persisted) restoreCurrentFragment("history-restoration");
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER);
|
||||||
|
window.addEventListener("touchmove", noteScrollIntent, PASSIVE_LISTENER);
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
window.addEventListener("popstate", handlePopstate);
|
||||||
|
window.addEventListener("pageshow", handlePageshow);
|
||||||
|
|
||||||
|
if (window.location.hash !== "") restoreCurrentFragment("initial");
|
||||||
|
|
||||||
|
teardown = (): void => {
|
||||||
|
window.removeEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER);
|
||||||
|
window.removeEventListener(
|
||||||
|
"touchmove",
|
||||||
|
noteScrollIntent,
|
||||||
|
PASSIVE_LISTENER,
|
||||||
|
);
|
||||||
|
window.removeEventListener("keydown", handleKeydown);
|
||||||
|
window.removeEventListener("popstate", handlePopstate);
|
||||||
|
window.removeEventListener("pageshow", handlePageshow);
|
||||||
|
unsubscribe();
|
||||||
|
controller?.dispose();
|
||||||
|
controller = undefined;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => teardown());
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: readonly(state),
|
||||||
|
onSectionLinkClick,
|
||||||
|
notePassiveSection(id) {
|
||||||
|
controller?.notePassiveSection(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
321
src/journey/navigation-controller.ts
Normal file
321
src/journey/navigation-controller.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
import type { JourneySource } from "./types";
|
||||||
|
|
||||||
|
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<HTMLElement>(
|
||||||
|
':scope > 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<HTMLElement>(':scope > 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,
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 => {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/journey/section-controller.ts
Normal file
78
src/journey/section-controller.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
import type { EnvironmentChapter } from "./types";
|
||||||
|
|
||||||
|
export interface SectionHeadingGeometry {
|
||||||
|
id: SectionId;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveSectionInput {
|
||||||
|
headings: readonly SectionHeadingGeometry[];
|
||||||
|
activationY: number;
|
||||||
|
atDocumentEnd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DerivedSectionState {
|
||||||
|
activeSectionId: SectionId;
|
||||||
|
chapter: EnvironmentChapter;
|
||||||
|
arrived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivationLineInput {
|
||||||
|
scrollY: number;
|
||||||
|
visualViewportOffsetTop: number;
|
||||||
|
visualViewportHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activationLineDocumentY = (input: ActivationLineInput): number =>
|
||||||
|
input.scrollY +
|
||||||
|
input.visualViewportOffsetTop +
|
||||||
|
input.visualViewportHeight * 0.35;
|
||||||
|
|
||||||
|
export interface DocumentEndInput {
|
||||||
|
scrollY: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
visualViewportHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAtDocumentEnd = (input: DocumentEndInput): boolean =>
|
||||||
|
Math.max(0, input.scrollHeight - input.visualViewportHeight) -
|
||||||
|
input.scrollY <=
|
||||||
|
2;
|
||||||
|
|
||||||
|
export function selectActiveSection(input: ActiveSectionInput): SectionId {
|
||||||
|
const first = input.headings[0];
|
||||||
|
if (first === undefined) {
|
||||||
|
throw new Error("At least one canonical section heading is required.");
|
||||||
|
}
|
||||||
|
if (input.atDocumentEnd) {
|
||||||
|
return input.headings[input.headings.length - 1]?.id ?? first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = first.id;
|
||||||
|
for (const heading of input.headings) {
|
||||||
|
if (heading.top <= input.activationY) active = heading.id;
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapters: Readonly<Record<SectionId, EnvironmentChapter>> = {
|
||||||
|
start: "deep-night",
|
||||||
|
profile: "deep-night",
|
||||||
|
experience: "systems",
|
||||||
|
infrastructure: "infrastructure",
|
||||||
|
"selected-builds": "builds",
|
||||||
|
"additional-work": "builds",
|
||||||
|
toolkit: "pre-dawn",
|
||||||
|
contact: "destination",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deriveSectionState(
|
||||||
|
activeSectionId: SectionId,
|
||||||
|
): DerivedSectionState {
|
||||||
|
return {
|
||||||
|
activeSectionId,
|
||||||
|
chapter: chapters[activeSectionId],
|
||||||
|
arrived: activeSectionId === "contact",
|
||||||
|
};
|
||||||
|
}
|
||||||
53
src/journey/sign-controller.ts
Normal file
53
src/journey/sign-controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
import type { JourneySource, SignSnapshot } from "./types";
|
||||||
|
|
||||||
|
export interface SignSample {
|
||||||
|
source: JourneySource;
|
||||||
|
deltaY: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
crossedBoundaries: number;
|
||||||
|
candidateId: SectionId | null;
|
||||||
|
distanceVh: number;
|
||||||
|
motionAllowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
|
export function nextSign(
|
||||||
|
current: SignSnapshot | null,
|
||||||
|
input: SignSample,
|
||||||
|
): SignSnapshot | null {
|
||||||
|
const eligible =
|
||||||
|
input.motionAllowed &&
|
||||||
|
input.source === "passive-scroll" &&
|
||||||
|
Number.isFinite(input.deltaY) &&
|
||||||
|
Number.isFinite(input.viewportHeight) &&
|
||||||
|
Number.isFinite(input.distanceVh) &&
|
||||||
|
input.viewportHeight > 0 &&
|
||||||
|
input.deltaY > 0 &&
|
||||||
|
input.deltaY <= input.viewportHeight * 0.75 &&
|
||||||
|
input.crossedBoundaries <= 1;
|
||||||
|
|
||||||
|
if (!eligible || input.distanceVh > 1.2 || input.distanceVh <= -0.25) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = current?.targetId ?? input.candidateId;
|
||||||
|
if (targetId === null) return null;
|
||||||
|
|
||||||
|
if (input.distanceVh > 0.55) {
|
||||||
|
return {
|
||||||
|
targetId,
|
||||||
|
phase: "approach",
|
||||||
|
progress: clamp01((1.2 - input.distanceVh) / 0.65),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.distanceVh > 0.15) {
|
||||||
|
return { targetId, phase: "stabilized", progress: 1 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
targetId,
|
||||||
|
phase: "pass",
|
||||||
|
progress: clamp01((0.15 - input.distanceVh) / 0.4),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
|
||||||
export interface ScrollDriveSample {
|
export interface ScrollDriveSample {
|
||||||
scrollY: number;
|
scrollY: number;
|
||||||
now: number;
|
now: number;
|
||||||
@@ -16,3 +18,27 @@ export interface ScrollDriveController {
|
|||||||
sample(input: ScrollDriveSample): ScrollDriveSnapshot;
|
sample(input: ScrollDriveSample): ScrollDriveSnapshot;
|
||||||
reset(input?: ScrollDriveSample): ScrollDriveSnapshot;
|
reset(input?: ScrollDriveSample): ScrollDriveSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JourneySource =
|
||||||
|
"initial" | "explicit-navigation" | "passive-scroll" | "history-restoration";
|
||||||
|
|
||||||
|
export type EnvironmentChapter =
|
||||||
|
| "deep-night"
|
||||||
|
| "systems"
|
||||||
|
| "infrastructure"
|
||||||
|
| "builds"
|
||||||
|
| "pre-dawn"
|
||||||
|
| "destination";
|
||||||
|
|
||||||
|
export interface SignSnapshot {
|
||||||
|
targetId: SectionId;
|
||||||
|
phase: "approach" | "stabilized" | "pass";
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneySnapshot extends ScrollDriveSnapshot {
|
||||||
|
activeSectionId: SectionId;
|
||||||
|
chapter: EnvironmentChapter;
|
||||||
|
sign: SignSnapshot | null;
|
||||||
|
arrived: boolean;
|
||||||
|
}
|
||||||
|
|||||||
105
tests/browser/navigation.spec.ts
Normal file
105
tests/browser/navigation.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { expect, test } from "../helpers/serve-dist";
|
||||||
|
|
||||||
|
test("keeps canonical hrefs while creating exactly one or zero explicit history entries", async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||||
|
const initialLength = await page.evaluate(() => history.length);
|
||||||
|
const start = page.locator('nav a[href="#start"]');
|
||||||
|
const profile = page.locator('nav a[href="#profile"]');
|
||||||
|
|
||||||
|
await start.click();
|
||||||
|
await expect(page).toHaveURL(`${distUrl}/`);
|
||||||
|
expect(await page.evaluate(() => history.length)).toBe(initialLength);
|
||||||
|
await expect(page.locator("#start-heading")).toBeFocused();
|
||||||
|
|
||||||
|
await profile.click();
|
||||||
|
await expect(page).toHaveURL(`${distUrl}/#profile`);
|
||||||
|
expect(await page.evaluate(() => history.length)).toBe(initialLength + 1);
|
||||||
|
await expect(page.locator("#profile-heading")).toBeFocused();
|
||||||
|
|
||||||
|
await profile.click();
|
||||||
|
expect(await page.evaluate(() => history.length)).toBe(initialLength + 1);
|
||||||
|
await expect(page.locator("#profile-heading")).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancels pending explicit focus when wheel intent intervenes", async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||||
|
const link = page.locator('nav a[href="#infrastructure"]');
|
||||||
|
await link.focus();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const anchor = document.querySelector<HTMLAnchorElement>(
|
||||||
|
'nav a[href="#infrastructure"]',
|
||||||
|
);
|
||||||
|
anchor?.addEventListener(
|
||||||
|
"click",
|
||||||
|
() =>
|
||||||
|
queueMicrotask(() =>
|
||||||
|
window.dispatchEvent(new WheelEvent("wheel", { deltaY: 40 })),
|
||||||
|
),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await link.click();
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await expect(link).toBeFocused();
|
||||||
|
await expect(page.locator("#infrastructure-heading")).not.toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passive scrolling replaces a stable fragment without stealing focus", async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||||
|
const link = page.locator('nav a[href="#start"]');
|
||||||
|
await link.focus();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.dispatchEvent(new WheelEvent("wheel", { deltaY: 20 }));
|
||||||
|
const heading = document.querySelector<HTMLElement>("#experience-heading");
|
||||||
|
if (heading === null) throw new Error("Missing experience heading");
|
||||||
|
window.scrollTo(0, heading.offsetTop - window.innerHeight * 0.35);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(`${distUrl}/#experience`, { timeout: 2000 });
|
||||||
|
await expect(link).toBeFocused();
|
||||||
|
await expect(page.locator("#experience-heading")).not.toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("back and forward restore sections without another history mutation or focus move", async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator('nav a[href="#profile"]').click();
|
||||||
|
await page.locator('nav a[href="#experience"]').click();
|
||||||
|
const historyLength = await page.evaluate(() => history.length);
|
||||||
|
await expect(page.locator("#experience-heading")).toBeFocused();
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(`${distUrl}/#profile`);
|
||||||
|
await expect(page.locator("#profile")).toBeInViewport();
|
||||||
|
expect(await page.evaluate(() => history.length)).toBe(historyLength);
|
||||||
|
await expect(page.locator("#profile-heading")).not.toBeFocused();
|
||||||
|
|
||||||
|
await page.goForward();
|
||||||
|
await expect(page).toHaveURL(`${distUrl}/#experience`);
|
||||||
|
await expect(page.locator("#experience")).toBeInViewport();
|
||||||
|
expect(await page.evaluate(() => history.length)).toBe(historyLength);
|
||||||
|
await expect(page.locator("#experience-heading")).not.toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct initial fragments align without moving focus", async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`${distUrl}/#toolkit`, { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await expect(page.locator("#toolkit")).toBeInViewport();
|
||||||
|
await expect(page.locator("#toolkit-heading")).not.toBeFocused();
|
||||||
|
});
|
||||||
283
tests/unit/navigation-controller.test.ts
Normal file
283
tests/unit/navigation-controller.test.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
canonicalSectionIdFromHash,
|
||||||
|
createNavigationController,
|
||||||
|
validateSectionAnchor,
|
||||||
|
type NavigationRuntime,
|
||||||
|
} from "@/journey/navigation-controller";
|
||||||
|
import type { SectionId } from "@/content/types";
|
||||||
|
import { isScrollIntentKey } from "@/composables/useKeyboardNavigation";
|
||||||
|
|
||||||
|
const sectionIds = [
|
||||||
|
"start",
|
||||||
|
"profile",
|
||||||
|
"experience",
|
||||||
|
"infrastructure",
|
||||||
|
"selected-builds",
|
||||||
|
"additional-work",
|
||||||
|
"toolkit",
|
||||||
|
"contact",
|
||||||
|
] as const satisfies readonly SectionId[];
|
||||||
|
|
||||||
|
interface RuntimeHarness {
|
||||||
|
runtime: NavigationRuntime;
|
||||||
|
hash: string;
|
||||||
|
scrollY: number;
|
||||||
|
pushes: string[];
|
||||||
|
replacements: string[];
|
||||||
|
scrolls: Array<{ id: string; behavior: ScrollBehavior }>;
|
||||||
|
focused: string[];
|
||||||
|
runFrame(scrollY?: number): void;
|
||||||
|
runTimer(delay: number): void;
|
||||||
|
emitScrollEnd(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRuntime = (supportsScrollEnd: boolean): RuntimeHarness => {
|
||||||
|
const frames = new Map<number, FrameRequestCallback>();
|
||||||
|
const timers = new Map<number, { callback: () => void; delay: number }>();
|
||||||
|
const scrollEndListeners = new Set<EventListener>();
|
||||||
|
let nextId = 1;
|
||||||
|
const harness: RuntimeHarness = {
|
||||||
|
hash: "",
|
||||||
|
scrollY: 0,
|
||||||
|
pushes: [],
|
||||||
|
replacements: [],
|
||||||
|
scrolls: [],
|
||||||
|
focused: [],
|
||||||
|
runtime: undefined as never,
|
||||||
|
runFrame(nextScrollY) {
|
||||||
|
if (nextScrollY !== undefined) harness.scrollY = nextScrollY;
|
||||||
|
const entry = frames.entries().next().value as
|
||||||
|
[number, FrameRequestCallback] | undefined;
|
||||||
|
if (entry === undefined) throw new Error("No pending animation frame");
|
||||||
|
frames.delete(entry[0]);
|
||||||
|
entry[1](0);
|
||||||
|
},
|
||||||
|
runTimer(delay) {
|
||||||
|
const entry = [...timers.entries()].find(
|
||||||
|
([, timer]) => timer.delay === delay,
|
||||||
|
);
|
||||||
|
if (entry === undefined) throw new Error(`No pending ${delay} ms timer`);
|
||||||
|
timers.delete(entry[0]);
|
||||||
|
entry[1].callback();
|
||||||
|
},
|
||||||
|
emitScrollEnd() {
|
||||||
|
for (const listener of [...scrollEndListeners])
|
||||||
|
listener(new Event("scrollend"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
harness.runtime = {
|
||||||
|
getHash: () => harness.hash,
|
||||||
|
pushHash: (hash) => {
|
||||||
|
harness.pushes.push(hash);
|
||||||
|
harness.hash = hash;
|
||||||
|
},
|
||||||
|
replaceHash: (hash) => {
|
||||||
|
harness.replacements.push(hash);
|
||||||
|
harness.hash = hash;
|
||||||
|
},
|
||||||
|
scrollTarget: (target, behavior) =>
|
||||||
|
harness.scrolls.push({ id: target.id, behavior }),
|
||||||
|
focusHeading: (target) => harness.focused.push(target.id),
|
||||||
|
getScrollY: () => harness.scrollY,
|
||||||
|
requestFrame: (callback) => {
|
||||||
|
const id = nextId++;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancelFrame: (id) => frames.delete(id),
|
||||||
|
setTimer: (callback, delay) => {
|
||||||
|
const id = nextId++;
|
||||||
|
timers.set(id, { callback, delay });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimer: (id) => timers.delete(id),
|
||||||
|
supportsScrollEnd,
|
||||||
|
addScrollEndListener: (listener) => scrollEndListeners.add(listener),
|
||||||
|
removeScrollEndListener: (listener) => scrollEndListeners.delete(listener),
|
||||||
|
};
|
||||||
|
return harness;
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = (id: SectionId): HTMLElement => {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.id = id;
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.tabIndex = -1;
|
||||||
|
section.append(heading);
|
||||||
|
return section;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("canonical fragment validation", () => {
|
||||||
|
it("recognizes every specified keyboard scroll intent and no activation key", () => {
|
||||||
|
for (const key of [
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
" ",
|
||||||
|
"Spacebar",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
]) {
|
||||||
|
expect(isScrollIntentKey(key)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isScrollIntentKey("Enter")).toBe(false);
|
||||||
|
expect(isScrollIntentKey("Tab")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes exact canonical IDs and treats an empty fragment as Start", () => {
|
||||||
|
expect(canonicalSectionIdFromHash("", sectionIds)).toBe("start");
|
||||||
|
expect(canonicalSectionIdFromHash("#selected%2Dbuilds", sectionIds)).toBe(
|
||||||
|
"selected-builds",
|
||||||
|
);
|
||||||
|
expect(canonicalSectionIdFromHash("#unknown", sectionIds)).toBeNull();
|
||||||
|
expect(canonicalSectionIdFromHash("#%E0%A4%A", sectionIds)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates a same-document native href and its exact target before interception", () => {
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = "#profile";
|
||||||
|
document.body.append(target("profile"), anchor);
|
||||||
|
|
||||||
|
expect(validateSectionAnchor(anchor, sectionIds)).toEqual({
|
||||||
|
id: "profile",
|
||||||
|
target: document.getElementById("profile"),
|
||||||
|
});
|
||||||
|
|
||||||
|
anchor.href = "https://example.com/#profile";
|
||||||
|
expect(validateSectionAnchor(anchor, sectionIds)).toBeNull();
|
||||||
|
anchor.href = "#unknown";
|
||||||
|
expect(validateSectionAnchor(anchor, sectionIds)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("navigation epochs and effects", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes exactly once for a different fragment and zero times for the current fragment", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
|
||||||
|
expect(controller.navigate("profile", target("profile"))).toBe(1);
|
||||||
|
expect(harness.pushes).toEqual(["#profile"]);
|
||||||
|
expect(harness.scrolls).toEqual([{ id: "profile", behavior: "smooth" }]);
|
||||||
|
|
||||||
|
expect(controller.navigate("profile", target("profile"))).toBe(2);
|
||||||
|
expect(harness.pushes).toEqual(["#profile"]);
|
||||||
|
expect(harness.scrolls).toEqual([
|
||||||
|
{ id: "profile", behavior: "smooth" },
|
||||||
|
{ id: "profile", behavior: "smooth" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments epochs and cancels older focus work on user scroll intent", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
controller.navigate("profile", target("profile"));
|
||||||
|
|
||||||
|
expect(controller.noteUserScrollIntent()).toBe(2);
|
||||||
|
harness.emitScrollEnd();
|
||||||
|
expect(harness.focused).toEqual([]);
|
||||||
|
expect(controller.state()).toEqual({
|
||||||
|
epoch: 2,
|
||||||
|
source: "passive-scroll",
|
||||||
|
destinationId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses native scrollend and focuses on the next frame only for the current explicit epoch", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
controller.navigate("experience", target("experience"));
|
||||||
|
|
||||||
|
harness.emitScrollEnd();
|
||||||
|
expect(harness.focused).toEqual([]);
|
||||||
|
harness.runFrame();
|
||||||
|
expect(harness.focused).toEqual(["experience"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to three consecutive sub-pixel-stable animation frames", () => {
|
||||||
|
const harness = createRuntime(false);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
controller.navigate("toolkit", target("toolkit"));
|
||||||
|
|
||||||
|
harness.runFrame(20);
|
||||||
|
harness.runFrame(20.5);
|
||||||
|
harness.runFrame(21.4);
|
||||||
|
expect(harness.focused).toEqual([]);
|
||||||
|
harness.runFrame(21.9);
|
||||||
|
expect(harness.focused).toEqual([]);
|
||||||
|
harness.runFrame();
|
||||||
|
expect(harness.focused).toEqual(["toolkit"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs one instant final alignment after 1500 ms before focusing", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
controller.navigate("contact", target("contact"));
|
||||||
|
|
||||||
|
harness.runTimer(1500);
|
||||||
|
expect(harness.scrolls).toEqual([
|
||||||
|
{ id: "contact", behavior: "smooth" },
|
||||||
|
{ id: "contact", behavior: "instant" },
|
||||||
|
]);
|
||||||
|
harness.runFrame();
|
||||||
|
expect(harness.focused).toEqual(["contact"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces one stable passive fragment after 150 ms and cancels stale replacements", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
controller.noteUserScrollIntent();
|
||||||
|
controller.notePassiveSection("profile");
|
||||||
|
controller.notePassiveSection("experience");
|
||||||
|
|
||||||
|
harness.runTimer(150);
|
||||||
|
expect(harness.replacements).toEqual(["#experience"]);
|
||||||
|
|
||||||
|
controller.notePassiveSection("toolkit");
|
||||||
|
controller.noteUserScrollIntent();
|
||||||
|
expect(() => harness.runTimer(150)).toThrow("No pending 150 ms timer");
|
||||||
|
expect(harness.replacements).toEqual(["#experience"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores initial and history fragments without history mutation or focus", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
|
||||||
|
expect(controller.initialize("toolkit", target("toolkit"))).toBe(1);
|
||||||
|
expect(controller.restore("profile", target("profile"))).toBe(2);
|
||||||
|
harness.runFrame();
|
||||||
|
expect(harness.pushes).toEqual([]);
|
||||||
|
expect(harness.replacements).toEqual([]);
|
||||||
|
expect(harness.scrolls).toEqual([
|
||||||
|
{ id: "toolkit", behavior: "instant" },
|
||||||
|
{ id: "profile", behavior: "instant" },
|
||||||
|
]);
|
||||||
|
expect(harness.focused).toEqual([]);
|
||||||
|
expect(controller.state()).toEqual({
|
||||||
|
epoch: 2,
|
||||||
|
source: "history-restoration",
|
||||||
|
destinationId: "profile",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an older deferred restoration during rapid back and forward", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
|
||||||
|
controller.restore("profile", target("profile"));
|
||||||
|
controller.restore("experience", target("experience"));
|
||||||
|
expect(harness.scrolls).toEqual([]);
|
||||||
|
harness.runFrame();
|
||||||
|
expect(harness.scrolls).toEqual([
|
||||||
|
{ id: "experience", behavior: "instant" },
|
||||||
|
]);
|
||||||
|
expect(harness.focused).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
tests/unit/section-controller.test.ts
Normal file
137
tests/unit/section-controller.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
activationLineDocumentY,
|
||||||
|
deriveSectionState,
|
||||||
|
isAtDocumentEnd,
|
||||||
|
selectActiveSection,
|
||||||
|
type SectionHeadingGeometry,
|
||||||
|
} from "@/journey/section-controller";
|
||||||
|
|
||||||
|
const headings: readonly SectionHeadingGeometry[] = [
|
||||||
|
{ id: "start", top: 100 },
|
||||||
|
{ id: "profile", top: 350 },
|
||||||
|
{ id: "experience", top: 700 },
|
||||||
|
{ id: "infrastructure", top: 1100 },
|
||||||
|
{ id: "selected-builds", top: 1500 },
|
||||||
|
{ id: "additional-work", top: 1900 },
|
||||||
|
{ id: "toolkit", top: 2300 },
|
||||||
|
{ id: "contact", top: 2700 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("active section selection", () => {
|
||||||
|
it("places the activation line at a literal 35 percent of the visual viewport", () => {
|
||||||
|
expect(
|
||||||
|
activationLineDocumentY({
|
||||||
|
scrollY: 200,
|
||||||
|
visualViewportOffsetTop: 25,
|
||||||
|
visualViewportHeight: 1000,
|
||||||
|
}),
|
||||||
|
).toBe(575);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces document end within exactly two CSS pixels using current geometry", () => {
|
||||||
|
expect(
|
||||||
|
isAtDocumentEnd({
|
||||||
|
scrollY: 498,
|
||||||
|
scrollHeight: 1000,
|
||||||
|
visualViewportHeight: 500,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAtDocumentEnd({
|
||||||
|
scrollY: 497.999,
|
||||||
|
scrollHeight: 1000,
|
||||||
|
visualViewportHeight: 500,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isAtDocumentEnd({
|
||||||
|
scrollY: 498,
|
||||||
|
scrollHeight: 1200,
|
||||||
|
visualViewportHeight: 500,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first section before any heading reaches the activation line", () => {
|
||||||
|
expect(
|
||||||
|
selectActiveSection({ headings, activationY: 99, atDocumentEnd: false }),
|
||||||
|
).toBe("start");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the last heading at or above the literal 35 percent activation line", () => {
|
||||||
|
expect(
|
||||||
|
selectActiveSection({
|
||||||
|
headings,
|
||||||
|
activationY: 699.999,
|
||||||
|
atDocumentEnd: false,
|
||||||
|
}),
|
||||||
|
).toBe("profile");
|
||||||
|
expect(
|
||||||
|
selectActiveSection({ headings, activationY: 700, atDocumentEnd: false }),
|
||||||
|
).toBe("experience");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets later document order win when heading positions are identical", () => {
|
||||||
|
expect(
|
||||||
|
selectActiveSection({
|
||||||
|
headings: [
|
||||||
|
{ id: "start", top: 0 },
|
||||||
|
{ id: "profile", top: 350 },
|
||||||
|
{ id: "experience", top: 350 },
|
||||||
|
],
|
||||||
|
activationY: 350,
|
||||||
|
atDocumentEnd: false,
|
||||||
|
}),
|
||||||
|
).toBe("experience");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces the final canonical section at document end", () => {
|
||||||
|
expect(
|
||||||
|
selectActiveSection({ headings, activationY: 1400, atDocumentEnd: true }),
|
||||||
|
).toBe("contact");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fresh late-growth geometry on every selection", () => {
|
||||||
|
const grown = headings.map((heading) =>
|
||||||
|
heading.id === "experience" ? { ...heading, top: 900 } : heading,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectActiveSection({ headings, activationY: 750, atDocumentEnd: false }),
|
||||||
|
).toBe("experience");
|
||||||
|
expect(
|
||||||
|
selectActiveSection({
|
||||||
|
headings: grown,
|
||||||
|
activationY: 750,
|
||||||
|
atDocumentEnd: false,
|
||||||
|
}),
|
||||||
|
).toBe("profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps every canonical section to its chapter and arrival state", () => {
|
||||||
|
expect([
|
||||||
|
deriveSectionState("start"),
|
||||||
|
deriveSectionState("profile"),
|
||||||
|
deriveSectionState("experience"),
|
||||||
|
deriveSectionState("infrastructure"),
|
||||||
|
deriveSectionState("selected-builds"),
|
||||||
|
deriveSectionState("additional-work"),
|
||||||
|
deriveSectionState("toolkit"),
|
||||||
|
deriveSectionState("contact"),
|
||||||
|
]).toEqual([
|
||||||
|
{ activeSectionId: "start", chapter: "deep-night", arrived: false },
|
||||||
|
{ activeSectionId: "profile", chapter: "deep-night", arrived: false },
|
||||||
|
{ activeSectionId: "experience", chapter: "systems", arrived: false },
|
||||||
|
{
|
||||||
|
activeSectionId: "infrastructure",
|
||||||
|
chapter: "infrastructure",
|
||||||
|
arrived: false,
|
||||||
|
},
|
||||||
|
{ activeSectionId: "selected-builds", chapter: "builds", arrived: false },
|
||||||
|
{ activeSectionId: "additional-work", chapter: "builds", arrived: false },
|
||||||
|
{ activeSectionId: "toolkit", chapter: "pre-dawn", arrived: false },
|
||||||
|
{ activeSectionId: "contact", chapter: "destination", arrived: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
tests/unit/sign-controller.test.ts
Normal file
100
tests/unit/sign-controller.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { nextSign } from "@/journey/sign-controller";
|
||||||
|
import type { SignSnapshot } from "@/journey/types";
|
||||||
|
|
||||||
|
const passiveSample = (
|
||||||
|
distanceVh: number,
|
||||||
|
overrides: Partial<Parameters<typeof nextSign>[1]> = {},
|
||||||
|
): Parameters<typeof nextSign>[1] => ({
|
||||||
|
source: "passive-scroll",
|
||||||
|
deltaY: 10,
|
||||||
|
viewportHeight: 100,
|
||||||
|
crossedBoundaries: 0,
|
||||||
|
candidateId: "profile",
|
||||||
|
distanceVh,
|
||||||
|
motionAllowed: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("forward-only sign latching", () => {
|
||||||
|
it("latches a first eligible candidate at the exact approach boundary", () => {
|
||||||
|
expect(nextSign(null, passiveSample(1.2))).toEqual({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "approach",
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the literal approach and pass ranges independently", () => {
|
||||||
|
expect(nextSign(null, passiveSample(0.875))).toEqual({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "approach",
|
||||||
|
progress: expect.closeTo(0.5, 14),
|
||||||
|
});
|
||||||
|
expect(nextSign(null, passiveSample(-0.05))).toEqual({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "pass",
|
||||||
|
progress: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses exact stabilized, pass, and completion boundaries", () => {
|
||||||
|
const latched: SignSnapshot = {
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "approach",
|
||||||
|
progress: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(nextSign(latched, passiveSample(0.55))).toEqual({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "stabilized",
|
||||||
|
progress: 1,
|
||||||
|
});
|
||||||
|
expect(nextSign(latched, passiveSample(0.15))).toEqual({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "pass",
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
expect(nextSign(latched, passiveSample(-0.25))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the one latched target while the semantic section advances", () => {
|
||||||
|
const latched: SignSnapshot = {
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "stabilized",
|
||||||
|
progress: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
nextSign(latched, passiveSample(0.1, { candidateId: "experience" })),
|
||||||
|
).toEqual({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "pass",
|
||||||
|
progress: expect.closeTo(0.125, 14),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["reverse input", { deltaY: -2 }],
|
||||||
|
["zero-direction input", { deltaY: 0 }],
|
||||||
|
["explicit navigation", { source: "explicit-navigation" as const }],
|
||||||
|
["history restoration", { source: "history-restoration" as const }],
|
||||||
|
["initial positioning", { source: "initial" as const }],
|
||||||
|
["more than one activation boundary", { crossedBoundaries: 2 }],
|
||||||
|
["a jump over 0.75 viewport heights", { deltaY: 76 }],
|
||||||
|
["disabled motion", { motionAllowed: false }],
|
||||||
|
])("clears or suppresses signs for %s", (_label, overrides) => {
|
||||||
|
const latched: SignSnapshot = {
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "approach",
|
||||||
|
progress: 0.5,
|
||||||
|
};
|
||||||
|
expect(nextSign(latched, passiveSample(0.5, overrides))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not latch before visibility or after a jump directly past completion", () => {
|
||||||
|
expect(nextSign(null, passiveSample(1.20001))).toBeNull();
|
||||||
|
expect(nextSign(null, passiveSample(-0.25))).toBeNull();
|
||||||
|
expect(nextSign(null, passiveSample(-1))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user