feat: expose stable journey intent signals
This commit is contained in:
@@ -59,11 +59,12 @@ const sectionLabel = (id: SectionId): string => {
|
|||||||
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
|
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
|
||||||
const motion = useMotionPreferences();
|
const motion = useMotionPreferences();
|
||||||
const navigation = useKeyboardNavigation(canonicalSectionIds);
|
const navigation = useKeyboardNavigation(canonicalSectionIds);
|
||||||
const journey = useJourneySections(
|
const journeyBinding = useJourneySections(
|
||||||
canonicalSectionIds,
|
canonicalSectionIds,
|
||||||
navigation.state,
|
navigation.state,
|
||||||
computed(() => motion.policy.value.allowSceneRequest),
|
computed(() => motion.policy.value.allowSceneRequest),
|
||||||
);
|
);
|
||||||
|
const journey = journeyBinding.snapshot;
|
||||||
const pendingAudioDestination = shallowRef<SectionId | null>(null);
|
const pendingAudioDestination = shallowRef<SectionId | null>(null);
|
||||||
let audioNavigationEpoch = -1;
|
let audioNavigationEpoch = -1;
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { SectionId } from '@/content/types';
|
|||||||
import type { NavigationState } from '@/journey/navigation-controller';
|
import type { NavigationState } from '@/journey/navigation-controller';
|
||||||
import {
|
import {
|
||||||
activationLineDocumentY,
|
activationLineDocumentY,
|
||||||
|
deriveSectionActivationSnapshot,
|
||||||
deriveSectionState,
|
deriveSectionState,
|
||||||
isAtDocumentEnd,
|
isAtDocumentEnd,
|
||||||
selectActiveSection,
|
selectActiveSection,
|
||||||
@@ -30,13 +31,26 @@ const initialJourney = (activeSectionId: SectionId): JourneySnapshot => ({
|
|||||||
driveSpeed: 0.18,
|
driveSpeed: 0.18,
|
||||||
...deriveSectionState(activeSectionId),
|
...deriveSectionState(activeSectionId),
|
||||||
sign: null,
|
sign: null,
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 0,
|
||||||
|
activeHeadingOffsetVh: 0,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface JourneySectionsBinding {
|
||||||
|
readonly snapshot: Readonly<Ref<JourneySnapshot>>;
|
||||||
|
requestFrame(): void;
|
||||||
|
subscribeFrame(
|
||||||
|
listener: (now: number, snapshot: JourneySnapshot) => boolean,
|
||||||
|
): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function useJourneySections(
|
export function useJourneySections(
|
||||||
canonicalIds: readonly SectionId[],
|
canonicalIds: readonly SectionId[],
|
||||||
navigationState: Readonly<Ref<NavigationState>>,
|
navigationState: Readonly<Ref<NavigationState>>,
|
||||||
motionAllowed?: Readonly<Ref<boolean>>,
|
motionAllowed?: Readonly<Ref<boolean>>,
|
||||||
): Readonly<Ref<JourneySnapshot>> {
|
): JourneySectionsBinding {
|
||||||
const firstId = canonicalIds[0];
|
const firstId = canonicalIds[0];
|
||||||
if (firstId === undefined) {
|
if (firstId === undefined) {
|
||||||
throw new Error('At least one canonical journey section is required.');
|
throw new Error('At least one canonical journey section is required.');
|
||||||
@@ -54,6 +68,10 @@ export function useJourneySections(
|
|||||||
}));
|
}));
|
||||||
let requestJourneyFrame = (): void => {};
|
let requestJourneyFrame = (): void => {};
|
||||||
let teardown = (): void => {};
|
let teardown = (): void => {};
|
||||||
|
const frameSubscribers = new Set<(
|
||||||
|
now: number,
|
||||||
|
snapshot: JourneySnapshot,
|
||||||
|
) => boolean>();
|
||||||
|
|
||||||
const stopNavigationWatch = watch(
|
const stopNavigationWatch = watch(
|
||||||
() => navigationState.value,
|
() => navigationState.value,
|
||||||
@@ -67,6 +85,7 @@ export function useJourneySections(
|
|||||||
const visualViewport = window.visualViewport;
|
const visualViewport = window.visualViewport;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let structureDirty = true;
|
let structureDirty = true;
|
||||||
|
let layoutRevision = 0;
|
||||||
let previousScrollY = drive.snapshot.value.scrollY;
|
let previousScrollY = drive.snapshot.value.scrollY;
|
||||||
let previousActiveId = firstId;
|
let previousActiveId = firstId;
|
||||||
let headings: readonly SectionHeadingGeometry[] = [];
|
let headings: readonly SectionHeadingGeometry[] = [];
|
||||||
@@ -86,6 +105,7 @@ export function useJourneySections(
|
|||||||
if (disposed) return false;
|
if (disposed) return false;
|
||||||
if (structureDirty) {
|
if (structureDirty) {
|
||||||
headings = readHeadings(driveSnapshot.scrollY);
|
headings = readHeadings(driveSnapshot.scrollY);
|
||||||
|
layoutRevision += 1;
|
||||||
structureDirty = false;
|
structureDirty = false;
|
||||||
}
|
}
|
||||||
if (headings.length === 0) return false;
|
if (headings.length === 0) return false;
|
||||||
@@ -118,6 +138,13 @@ export function useJourneySections(
|
|||||||
? null
|
? null
|
||||||
: (candidateHeading.top - activationY) / viewportHeight;
|
: (candidateHeading.top - activationY) / viewportHeight;
|
||||||
const sectionState = deriveSectionState(activeSectionId);
|
const sectionState = deriveSectionState(activeSectionId);
|
||||||
|
const sectionActivation = deriveSectionActivationSnapshot({
|
||||||
|
headings,
|
||||||
|
activeSectionId,
|
||||||
|
activationY,
|
||||||
|
viewportHeight,
|
||||||
|
layoutRevision,
|
||||||
|
});
|
||||||
const sign = signController.sample({
|
const sign = signController.sample({
|
||||||
now,
|
now,
|
||||||
source: navigationState.value.source,
|
source: navigationState.value.source,
|
||||||
@@ -139,11 +166,17 @@ export function useJourneySections(
|
|||||||
...driveSnapshot,
|
...driveSnapshot,
|
||||||
routeProgress: atDocumentEnd ? 1 : driveSnapshot.routeProgress,
|
routeProgress: atDocumentEnd ? 1 : driveSnapshot.routeProgress,
|
||||||
...sectionState,
|
...sectionState,
|
||||||
|
sectionActivation,
|
||||||
sign,
|
sign,
|
||||||
};
|
};
|
||||||
previousScrollY = driveSnapshot.scrollY;
|
previousScrollY = driveSnapshot.scrollY;
|
||||||
previousActiveId = activeSectionId;
|
previousActiveId = activeSectionId;
|
||||||
return signController.needsFrame();
|
const currentSnapshot = snapshot.value;
|
||||||
|
let keepRunning = signController.needsFrame();
|
||||||
|
for (const subscriber of [...frameSubscribers]) {
|
||||||
|
keepRunning = subscriber(now, currentSnapshot) || keepRunning;
|
||||||
|
}
|
||||||
|
return keepRunning;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFrameSubscriber = drive.subscribeFrame(measure);
|
const removeFrameSubscriber = drive.subscribeFrame(measure);
|
||||||
@@ -199,6 +232,7 @@ export function useJourneySections(
|
|||||||
document.removeEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
|
document.removeEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
signController.reset();
|
signController.reset();
|
||||||
|
frameSubscribers.clear();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,5 +242,12 @@ export function useJourneySections(
|
|||||||
teardown();
|
teardown();
|
||||||
});
|
});
|
||||||
|
|
||||||
return readonly(snapshot);
|
return {
|
||||||
|
snapshot: readonly(snapshot),
|
||||||
|
requestFrame: () => requestJourneyFrame(),
|
||||||
|
subscribeFrame(listener) {
|
||||||
|
frameSubscribers.add(listener);
|
||||||
|
return () => frameSubscribers.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function useKeyboardNavigation(
|
|||||||
const state = shallowRef<NavigationState>({
|
const state = shallowRef<NavigationState>({
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
source: "initial",
|
source: "initial",
|
||||||
|
phase: "idle",
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
signDestinationId: null,
|
signDestinationId: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
|||||||
const TEST_BUILD =
|
const TEST_BUILD =
|
||||||
typeof __PORTFOLIO_TEST_BUILD__ !== "undefined" && __PORTFOLIO_TEST_BUILD__;
|
typeof __PORTFOLIO_TEST_BUILD__ !== "undefined" && __PORTFOLIO_TEST_BUILD__;
|
||||||
|
|
||||||
|
export type NavigationPhase = "idle" | "travelling" | "settled";
|
||||||
|
|
||||||
export interface NavigationState {
|
export interface NavigationState {
|
||||||
epoch: number;
|
readonly epoch: number;
|
||||||
source: JourneySource;
|
readonly source: JourneySource;
|
||||||
destinationId: SectionId | null;
|
readonly phase: NavigationPhase;
|
||||||
signDestinationId: SectionId | null;
|
readonly destinationId: SectionId | null;
|
||||||
|
readonly signDestinationId: SectionId | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationRuntime {
|
export interface NavigationRuntime {
|
||||||
@@ -122,6 +125,7 @@ export function createNavigationController(
|
|||||||
let currentState: NavigationState = {
|
let currentState: NavigationState = {
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
source: "initial",
|
source: "initial",
|
||||||
|
phase: "idle",
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
signDestinationId: null,
|
signDestinationId: null,
|
||||||
};
|
};
|
||||||
@@ -171,6 +175,7 @@ export function createNavigationController(
|
|||||||
currentState = {
|
currentState = {
|
||||||
epoch: currentState.epoch + 1,
|
epoch: currentState.epoch + 1,
|
||||||
source,
|
source,
|
||||||
|
phase: source === "passive-scroll" ? "idle" : "travelling",
|
||||||
destinationId,
|
destinationId,
|
||||||
signDestinationId,
|
signDestinationId,
|
||||||
};
|
};
|
||||||
@@ -211,6 +216,8 @@ export function createNavigationController(
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
cancelSettle();
|
cancelSettle();
|
||||||
|
currentState = { ...currentState, phase: "settled" };
|
||||||
|
emit();
|
||||||
frameId = runtime.requestFrame(() => {
|
frameId = runtime.requestFrame(() => {
|
||||||
frameId = undefined;
|
frameId = undefined;
|
||||||
if (
|
if (
|
||||||
@@ -284,6 +291,14 @@ export function createNavigationController(
|
|||||||
const epoch = beginEpoch(source, id);
|
const epoch = beginEpoch(source, id);
|
||||||
if (source === "initial") {
|
if (source === "initial") {
|
||||||
runtime.scrollTarget(target, "instant");
|
runtime.scrollTarget(target, "instant");
|
||||||
|
if (
|
||||||
|
currentState.epoch === epoch &&
|
||||||
|
currentState.source === source &&
|
||||||
|
currentState.destinationId === id
|
||||||
|
) {
|
||||||
|
currentState = { ...currentState, phase: "settled" };
|
||||||
|
emit();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
frameId = runtime.requestFrame(() => {
|
frameId = runtime.requestFrame(() => {
|
||||||
frameId = undefined;
|
frameId = undefined;
|
||||||
@@ -293,6 +308,8 @@ export function createNavigationController(
|
|||||||
currentState.destinationId === id
|
currentState.destinationId === id
|
||||||
) {
|
) {
|
||||||
runtime.scrollTarget(target, "instant");
|
runtime.scrollTarget(target, "instant");
|
||||||
|
currentState = { ...currentState, phase: "settled" };
|
||||||
|
emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SectionId } from "@/content/types";
|
import type { SectionId } from "@/content/types";
|
||||||
import type { EnvironmentChapter } from "./types";
|
import type { EnvironmentChapter, SectionActivationSnapshot } from "./types";
|
||||||
|
|
||||||
export interface SectionHeadingGeometry {
|
export interface SectionHeadingGeometry {
|
||||||
id: SectionId;
|
id: SectionId;
|
||||||
@@ -12,6 +12,14 @@ export interface ActiveSectionInput {
|
|||||||
atDocumentEnd: boolean;
|
atDocumentEnd: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SectionActivationInput {
|
||||||
|
readonly headings: readonly SectionHeadingGeometry[];
|
||||||
|
readonly activeSectionId: SectionId;
|
||||||
|
readonly activationY: number;
|
||||||
|
readonly viewportHeight: number;
|
||||||
|
readonly layoutRevision: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DerivedSectionState {
|
export interface DerivedSectionState {
|
||||||
activeSectionId: SectionId;
|
activeSectionId: SectionId;
|
||||||
chapter: EnvironmentChapter;
|
chapter: EnvironmentChapter;
|
||||||
@@ -56,6 +64,31 @@ export function selectActiveSection(input: ActiveSectionInput): SectionId {
|
|||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deriveSectionActivationSnapshot(
|
||||||
|
input: SectionActivationInput,
|
||||||
|
): SectionActivationSnapshot {
|
||||||
|
const activeIndex = input.headings.findIndex(
|
||||||
|
(heading) => heading.id === input.activeSectionId,
|
||||||
|
);
|
||||||
|
const activeHeading = input.headings[activeIndex];
|
||||||
|
if (activeHeading === undefined || input.viewportHeight <= 0) {
|
||||||
|
return {
|
||||||
|
layoutRevision: input.layoutRevision,
|
||||||
|
activeHeadingOffsetVh: 0,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const nextHeading = input.headings[activeIndex + 1];
|
||||||
|
return {
|
||||||
|
layoutRevision: input.layoutRevision,
|
||||||
|
activeHeadingOffsetVh:
|
||||||
|
(activeHeading.top - input.activationY) / input.viewportHeight,
|
||||||
|
nextHeadingOffsetVh: nextHeading === undefined
|
||||||
|
? null
|
||||||
|
: (nextHeading.top - input.activationY) / input.viewportHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const chapters: Readonly<Record<SectionId, EnvironmentChapter>> = {
|
const chapters: Readonly<Record<SectionId, EnvironmentChapter>> = {
|
||||||
start: "deep-night",
|
start: "deep-night",
|
||||||
profile: "deep-night",
|
profile: "deep-night",
|
||||||
|
|||||||
@@ -66,9 +66,16 @@ export interface SignController {
|
|||||||
needsFrame(): boolean;
|
needsFrame(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SectionActivationSnapshot {
|
||||||
|
readonly layoutRevision: number;
|
||||||
|
readonly activeHeadingOffsetVh: number;
|
||||||
|
readonly nextHeadingOffsetVh: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JourneySnapshot extends ScrollDriveSnapshot {
|
export interface JourneySnapshot extends ScrollDriveSnapshot {
|
||||||
activeSectionId: SectionId;
|
activeSectionId: SectionId;
|
||||||
chapter: EnvironmentChapter;
|
chapter: EnvironmentChapter;
|
||||||
sign: SignSnapshot | null;
|
sign: SignSnapshot | null;
|
||||||
arrived: boolean;
|
arrived: boolean;
|
||||||
|
sectionActivation: SectionActivationSnapshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -582,6 +582,13 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
|
|||||||
: {}),
|
: {}),
|
||||||
} : null,
|
} : null,
|
||||||
arrived: Boolean(snapshot.arrived),
|
arrived: Boolean(snapshot.arrived),
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: snapshot.sectionActivation.layoutRevision,
|
||||||
|
activeHeadingOffsetVh: finite(snapshot.sectionActivation.activeHeadingOffsetVh),
|
||||||
|
nextHeadingOffsetVh: snapshot.sectionActivation.nextHeadingOffsetVh === null
|
||||||
|
? null
|
||||||
|
: finite(snapshot.sectionActivation.nextHeadingOffsetVh),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +607,11 @@ function initialJourneySnapshot(): JourneySnapshot {
|
|||||||
chapter: 'deep-night',
|
chapter: 'deep-night',
|
||||||
sign: null,
|
sign: null,
|
||||||
arrived: false,
|
arrived: false,
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 0,
|
||||||
|
activeHeadingOffsetVh: 0,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const journey = (
|
|||||||
chapter: "infrastructure",
|
chapter: "infrastructure",
|
||||||
sign: null,
|
sign: null,
|
||||||
arrived: false,
|
arrived: false,
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 0,
|
||||||
|
activeHeadingOffsetVh: 0,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
canonicalSectionIdFromHash,
|
canonicalSectionIdFromHash,
|
||||||
createNavigationController,
|
createNavigationController,
|
||||||
validateSectionAnchor,
|
validateSectionAnchor,
|
||||||
|
type NavigationState,
|
||||||
type NavigationRuntime,
|
type NavigationRuntime,
|
||||||
} from "@/journey/navigation-controller";
|
} from "@/journey/navigation-controller";
|
||||||
import type { SectionId } from "@/content/types";
|
import type { SectionId } from "@/content/types";
|
||||||
@@ -205,6 +206,7 @@ describe("navigation epochs and effects", () => {
|
|||||||
expect(controller.state()).toEqual({
|
expect(controller.state()).toEqual({
|
||||||
epoch: 2,
|
epoch: 2,
|
||||||
source: "passive-scroll",
|
source: "passive-scroll",
|
||||||
|
phase: "idle",
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
signDestinationId: null,
|
signDestinationId: null,
|
||||||
});
|
});
|
||||||
@@ -221,6 +223,30 @@ describe("navigation epochs and effects", () => {
|
|||||||
expect(harness.focused).toEqual(["experience"]);
|
expect(harness.focused).toEqual(["experience"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("publishes travelling and settled only for the latest explicit epoch", () => {
|
||||||
|
const harness = createRuntime(true);
|
||||||
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
const states: NavigationState[] = [];
|
||||||
|
controller.subscribe((state) => states.push(state));
|
||||||
|
|
||||||
|
controller.navigate("contact", target("contact"));
|
||||||
|
controller.navigate("toolkit", target("toolkit"));
|
||||||
|
expect(controller.state()).toMatchObject({
|
||||||
|
epoch: 2,
|
||||||
|
source: "explicit-navigation",
|
||||||
|
phase: "travelling",
|
||||||
|
destinationId: "toolkit",
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.emitScrollEnd();
|
||||||
|
expect(controller.state()).toMatchObject({
|
||||||
|
epoch: 2,
|
||||||
|
phase: "settled",
|
||||||
|
destinationId: "toolkit",
|
||||||
|
});
|
||||||
|
expect(states.filter((state) => state.phase === "settled")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to three consecutive sub-pixel-stable animation frames", () => {
|
it("falls back to three consecutive sub-pixel-stable animation frames", () => {
|
||||||
const harness = createRuntime(false);
|
const harness = createRuntime(false);
|
||||||
const controller = createNavigationController(sectionIds, harness.runtime);
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
@@ -271,7 +297,9 @@ describe("navigation epochs and effects", () => {
|
|||||||
const controller = createNavigationController(sectionIds, harness.runtime);
|
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||||
|
|
||||||
expect(controller.initialize("toolkit", target("toolkit"))).toBe(1);
|
expect(controller.initialize("toolkit", target("toolkit"))).toBe(1);
|
||||||
|
expect(controller.state()).toMatchObject({ phase: "settled" });
|
||||||
expect(controller.restore("profile", target("profile"))).toBe(2);
|
expect(controller.restore("profile", target("profile"))).toBe(2);
|
||||||
|
expect(controller.state()).toMatchObject({ phase: "travelling" });
|
||||||
harness.runFrame();
|
harness.runFrame();
|
||||||
expect(harness.pushes).toEqual([]);
|
expect(harness.pushes).toEqual([]);
|
||||||
expect(harness.replacements).toEqual([]);
|
expect(harness.replacements).toEqual([]);
|
||||||
@@ -283,6 +311,7 @@ describe("navigation epochs and effects", () => {
|
|||||||
expect(controller.state()).toEqual({
|
expect(controller.state()).toEqual({
|
||||||
epoch: 2,
|
epoch: 2,
|
||||||
source: "history-restoration",
|
source: "history-restoration",
|
||||||
|
phase: "settled",
|
||||||
destinationId: "profile",
|
destinationId: "profile",
|
||||||
signDestinationId: null,
|
signDestinationId: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -582,6 +582,11 @@ function journey(overrides: Partial<JourneySnapshot> = {}): JourneySnapshot {
|
|||||||
chapter: 'deep-night',
|
chapter: 'deep-night',
|
||||||
sign: null,
|
sign: null,
|
||||||
arrived: false,
|
arrived: false,
|
||||||
|
sectionActivation: {
|
||||||
|
layoutRevision: 0,
|
||||||
|
activeHeadingOffsetVh: 0,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,6 +465,7 @@ describe('useScrollDrive', () => {
|
|||||||
const navigation = shallowRef({
|
const navigation = shallowRef({
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
source: 'passive-scroll' as const,
|
source: 'passive-scroll' as const,
|
||||||
|
phase: 'idle' as const,
|
||||||
destinationId: null,
|
destinationId: null,
|
||||||
signDestinationId: null,
|
signDestinationId: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
activationLineDocumentY,
|
activationLineDocumentY,
|
||||||
|
deriveSectionActivationSnapshot,
|
||||||
deriveSectionState,
|
deriveSectionState,
|
||||||
isAtDocumentEnd,
|
isAtDocumentEnd,
|
||||||
selectActiveSection,
|
selectActiveSection,
|
||||||
@@ -109,6 +110,41 @@ describe("active section selection", () => {
|
|||||||
).toBe("profile");
|
).toBe("profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives responsive heading offsets from the same activation-line geometry", () => {
|
||||||
|
expect(deriveSectionActivationSnapshot({
|
||||||
|
headings: [
|
||||||
|
{ id: "start", top: 100 },
|
||||||
|
{ id: "profile", top: 420 },
|
||||||
|
{ id: "experience", top: 1_120 },
|
||||||
|
],
|
||||||
|
activeSectionId: "profile",
|
||||||
|
activationY: 500,
|
||||||
|
viewportHeight: 800,
|
||||||
|
layoutRevision: 7,
|
||||||
|
})).toEqual({
|
||||||
|
layoutRevision: 7,
|
||||||
|
activeHeadingOffsetVh: -0.1,
|
||||||
|
nextHeadingOffsetVh: 0.775,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no following-heading offset for the final active heading", () => {
|
||||||
|
expect(deriveSectionActivationSnapshot({
|
||||||
|
headings: [
|
||||||
|
{ id: "start", top: 100 },
|
||||||
|
{ id: "profile", top: 420 },
|
||||||
|
],
|
||||||
|
activeSectionId: "profile",
|
||||||
|
activationY: 500,
|
||||||
|
viewportHeight: 800,
|
||||||
|
layoutRevision: 8,
|
||||||
|
})).toEqual({
|
||||||
|
layoutRevision: 8,
|
||||||
|
activeHeadingOffsetVh: -0.1,
|
||||||
|
nextHeadingOffsetVh: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("maps every canonical section to its chapter and arrival state", () => {
|
it("maps every canonical section to its chapter and arrival state", () => {
|
||||||
expect([
|
expect([
|
||||||
deriveSectionState("start"),
|
deriveSectionState("start"),
|
||||||
|
|||||||
Reference in New Issue
Block a user