diff --git a/src/components/SiteNavigation.vue b/src/components/SiteNavigation.vue
index 981d1a0..d9f2534 100644
--- a/src/components/SiteNavigation.vue
+++ b/src/components/SiteNavigation.vue
@@ -1,43 +1,34 @@
diff --git a/src/composables/useJourneySections.ts b/src/composables/useJourneySections.ts
new file mode 100644
index 0000000..a2fbe19
--- /dev/null
+++ b/src/composables/useJourneySections.ts
@@ -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[>,
+ motionAllowed?: Readonly][>,
+): Readonly][> {
+ const firstId = canonicalIds[0];
+ if (firstId === undefined) {
+ throw new Error("At least one canonical journey section is required.");
+ }
+ const drive = useScrollDrive();
+ const snapshot = shallowRef(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);
+}
diff --git a/src/composables/useKeyboardNavigation.ts b/src/composables/useKeyboardNavigation.ts
new file mode 100644
index 0000000..3da75af
--- /dev/null
+++ b/src/composables/useKeyboardNavigation.ts
@@ -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][>;
+ onSectionLinkClick(event: MouseEvent): void;
+ notePassiveSection(id: SectionId): void;
+}
+
+export function useKeyboardNavigation(
+ canonicalIds: readonly SectionId[],
+): KeyboardNavigation {
+ const state = shallowRef({
+ 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);
+ },
+ };
+}
diff --git a/src/journey/navigation-controller.ts b/src/journey/navigation-controller.ts
new file mode 100644
index 0000000..d56e7a0
--- /dev/null
+++ b/src/journey/navigation-controller.ts
@@ -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(
+ ':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(':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();
+ },
+ };
+}
diff --git a/src/journey/section-controller.ts b/src/journey/section-controller.ts
new file mode 100644
index 0000000..888ab00
--- /dev/null
+++ b/src/journey/section-controller.ts
@@ -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> = {
+ 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",
+ };
+}
diff --git a/src/journey/sign-controller.ts b/src/journey/sign-controller.ts
new file mode 100644
index 0000000..ff77238
--- /dev/null
+++ b/src/journey/sign-controller.ts
@@ -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),
+ };
+}
diff --git a/src/journey/types.ts b/src/journey/types.ts
index be2abac..864eff9 100644
--- a/src/journey/types.ts
+++ b/src/journey/types.ts
@@ -1,3 +1,5 @@
+import type { SectionId } from "@/content/types";
+
export interface ScrollDriveSample {
scrollY: number;
now: number;
@@ -16,3 +18,27 @@ export interface ScrollDriveController {
sample(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;
+}
diff --git a/tests/browser/navigation.spec.ts b/tests/browser/navigation.spec.ts
new file mode 100644
index 0000000..87b8c9f
--- /dev/null
+++ b/tests/browser/navigation.spec.ts
@@ -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(
+ '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("#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();
+});
diff --git a/tests/unit/navigation-controller.test.ts b/tests/unit/navigation-controller.test.ts
new file mode 100644
index 0000000..1d3652a
--- /dev/null
+++ b/tests/unit/navigation-controller.test.ts
@@ -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();
+ const timers = new Map void; delay: number }>();
+ const scrollEndListeners = new Set();
+ 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([]);
+ });
+});
diff --git a/tests/unit/section-controller.test.ts b/tests/unit/section-controller.test.ts
new file mode 100644
index 0000000..03bd658
--- /dev/null
+++ b/tests/unit/section-controller.test.ts
@@ -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 },
+ ]);
+ });
+});
diff --git a/tests/unit/sign-controller.test.ts b/tests/unit/sign-controller.test.ts
new file mode 100644
index 0000000..5a2351f
--- /dev/null
+++ b/tests/unit/sign-controller.test.ts
@@ -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[1]> = {},
+): Parameters[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();
+ });
+});
]