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 motion = useMotionPreferences();
|
||||
const navigation = useKeyboardNavigation(canonicalSectionIds);
|
||||
const journey = useJourneySections(
|
||||
const journeyBinding = useJourneySections(
|
||||
canonicalSectionIds,
|
||||
navigation.state,
|
||||
computed(() => motion.policy.value.allowSceneRequest),
|
||||
);
|
||||
const journey = journeyBinding.snapshot;
|
||||
const pendingAudioDestination = shallowRef<SectionId | null>(null);
|
||||
let audioNavigationEpoch = -1;
|
||||
watch(
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { SectionId } from '@/content/types';
|
||||
import type { NavigationState } from '@/journey/navigation-controller';
|
||||
import {
|
||||
activationLineDocumentY,
|
||||
deriveSectionActivationSnapshot,
|
||||
deriveSectionState,
|
||||
isAtDocumentEnd,
|
||||
selectActiveSection,
|
||||
@@ -30,13 +31,26 @@ const initialJourney = (activeSectionId: SectionId): JourneySnapshot => ({
|
||||
driveSpeed: 0.18,
|
||||
...deriveSectionState(activeSectionId),
|
||||
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(
|
||||
canonicalIds: readonly SectionId[],
|
||||
navigationState: Readonly<Ref<NavigationState>>,
|
||||
motionAllowed?: Readonly<Ref<boolean>>,
|
||||
): Readonly<Ref<JourneySnapshot>> {
|
||||
): JourneySectionsBinding {
|
||||
const firstId = canonicalIds[0];
|
||||
if (firstId === undefined) {
|
||||
throw new Error('At least one canonical journey section is required.');
|
||||
@@ -54,6 +68,10 @@ export function useJourneySections(
|
||||
}));
|
||||
let requestJourneyFrame = (): void => {};
|
||||
let teardown = (): void => {};
|
||||
const frameSubscribers = new Set<(
|
||||
now: number,
|
||||
snapshot: JourneySnapshot,
|
||||
) => boolean>();
|
||||
|
||||
const stopNavigationWatch = watch(
|
||||
() => navigationState.value,
|
||||
@@ -67,6 +85,7 @@ export function useJourneySections(
|
||||
const visualViewport = window.visualViewport;
|
||||
let disposed = false;
|
||||
let structureDirty = true;
|
||||
let layoutRevision = 0;
|
||||
let previousScrollY = drive.snapshot.value.scrollY;
|
||||
let previousActiveId = firstId;
|
||||
let headings: readonly SectionHeadingGeometry[] = [];
|
||||
@@ -86,6 +105,7 @@ export function useJourneySections(
|
||||
if (disposed) return false;
|
||||
if (structureDirty) {
|
||||
headings = readHeadings(driveSnapshot.scrollY);
|
||||
layoutRevision += 1;
|
||||
structureDirty = false;
|
||||
}
|
||||
if (headings.length === 0) return false;
|
||||
@@ -118,6 +138,13 @@ export function useJourneySections(
|
||||
? null
|
||||
: (candidateHeading.top - activationY) / viewportHeight;
|
||||
const sectionState = deriveSectionState(activeSectionId);
|
||||
const sectionActivation = deriveSectionActivationSnapshot({
|
||||
headings,
|
||||
activeSectionId,
|
||||
activationY,
|
||||
viewportHeight,
|
||||
layoutRevision,
|
||||
});
|
||||
const sign = signController.sample({
|
||||
now,
|
||||
source: navigationState.value.source,
|
||||
@@ -139,11 +166,17 @@ export function useJourneySections(
|
||||
...driveSnapshot,
|
||||
routeProgress: atDocumentEnd ? 1 : driveSnapshot.routeProgress,
|
||||
...sectionState,
|
||||
sectionActivation,
|
||||
sign,
|
||||
};
|
||||
previousScrollY = driveSnapshot.scrollY;
|
||||
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);
|
||||
@@ -199,6 +232,7 @@ export function useJourneySections(
|
||||
document.removeEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
|
||||
resizeObserver.disconnect();
|
||||
signController.reset();
|
||||
frameSubscribers.clear();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -208,5 +242,12 @@ export function useJourneySections(
|
||||
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>({
|
||||
epoch: 0,
|
||||
source: "initial",
|
||||
phase: "idle",
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
});
|
||||
|
||||
@@ -5,11 +5,14 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD =
|
||||
typeof __PORTFOLIO_TEST_BUILD__ !== "undefined" && __PORTFOLIO_TEST_BUILD__;
|
||||
|
||||
export type NavigationPhase = "idle" | "travelling" | "settled";
|
||||
|
||||
export interface NavigationState {
|
||||
epoch: number;
|
||||
source: JourneySource;
|
||||
destinationId: SectionId | null;
|
||||
signDestinationId: SectionId | null;
|
||||
readonly epoch: number;
|
||||
readonly source: JourneySource;
|
||||
readonly phase: NavigationPhase;
|
||||
readonly destinationId: SectionId | null;
|
||||
readonly signDestinationId: SectionId | null;
|
||||
}
|
||||
|
||||
export interface NavigationRuntime {
|
||||
@@ -122,6 +125,7 @@ export function createNavigationController(
|
||||
let currentState: NavigationState = {
|
||||
epoch: 0,
|
||||
source: "initial",
|
||||
phase: "idle",
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
};
|
||||
@@ -171,6 +175,7 @@ export function createNavigationController(
|
||||
currentState = {
|
||||
epoch: currentState.epoch + 1,
|
||||
source,
|
||||
phase: source === "passive-scroll" ? "idle" : "travelling",
|
||||
destinationId,
|
||||
signDestinationId,
|
||||
};
|
||||
@@ -211,6 +216,8 @@ export function createNavigationController(
|
||||
)
|
||||
return;
|
||||
cancelSettle();
|
||||
currentState = { ...currentState, phase: "settled" };
|
||||
emit();
|
||||
frameId = runtime.requestFrame(() => {
|
||||
frameId = undefined;
|
||||
if (
|
||||
@@ -284,6 +291,14 @@ export function createNavigationController(
|
||||
const epoch = beginEpoch(source, id);
|
||||
if (source === "initial") {
|
||||
runtime.scrollTarget(target, "instant");
|
||||
if (
|
||||
currentState.epoch === epoch &&
|
||||
currentState.source === source &&
|
||||
currentState.destinationId === id
|
||||
) {
|
||||
currentState = { ...currentState, phase: "settled" };
|
||||
emit();
|
||||
}
|
||||
} else {
|
||||
frameId = runtime.requestFrame(() => {
|
||||
frameId = undefined;
|
||||
@@ -293,6 +308,8 @@ export function createNavigationController(
|
||||
currentState.destinationId === id
|
||||
) {
|
||||
runtime.scrollTarget(target, "instant");
|
||||
currentState = { ...currentState, phase: "settled" };
|
||||
emit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { EnvironmentChapter } from "./types";
|
||||
import type { EnvironmentChapter, SectionActivationSnapshot } from "./types";
|
||||
|
||||
export interface SectionHeadingGeometry {
|
||||
id: SectionId;
|
||||
@@ -12,6 +12,14 @@ export interface ActiveSectionInput {
|
||||
atDocumentEnd: boolean;
|
||||
}
|
||||
|
||||
export interface SectionActivationInput {
|
||||
readonly headings: readonly SectionHeadingGeometry[];
|
||||
readonly activeSectionId: SectionId;
|
||||
readonly activationY: number;
|
||||
readonly viewportHeight: number;
|
||||
readonly layoutRevision: number;
|
||||
}
|
||||
|
||||
export interface DerivedSectionState {
|
||||
activeSectionId: SectionId;
|
||||
chapter: EnvironmentChapter;
|
||||
@@ -56,6 +64,31 @@ export function selectActiveSection(input: ActiveSectionInput): SectionId {
|
||||
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>> = {
|
||||
start: "deep-night",
|
||||
profile: "deep-night",
|
||||
|
||||
@@ -66,9 +66,16 @@ export interface SignController {
|
||||
needsFrame(): boolean;
|
||||
}
|
||||
|
||||
export interface SectionActivationSnapshot {
|
||||
readonly layoutRevision: number;
|
||||
readonly activeHeadingOffsetVh: number;
|
||||
readonly nextHeadingOffsetVh: number | null;
|
||||
}
|
||||
|
||||
export interface JourneySnapshot extends ScrollDriveSnapshot {
|
||||
activeSectionId: SectionId;
|
||||
chapter: EnvironmentChapter;
|
||||
sign: SignSnapshot | null;
|
||||
arrived: boolean;
|
||||
sectionActivation: SectionActivationSnapshot;
|
||||
}
|
||||
|
||||
@@ -582,6 +582,13 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
|
||||
: {}),
|
||||
} : null,
|
||||
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',
|
||||
sign: null,
|
||||
arrived: false,
|
||||
sectionActivation: {
|
||||
layoutRevision: 0,
|
||||
activeHeadingOffsetVh: 0,
|
||||
nextHeadingOffsetVh: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ const journey = (
|
||||
chapter: "infrastructure",
|
||||
sign: null,
|
||||
arrived: false,
|
||||
sectionActivation: {
|
||||
layoutRevision: 0,
|
||||
activeHeadingOffsetVh: 0,
|
||||
nextHeadingOffsetVh: null,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
canonicalSectionIdFromHash,
|
||||
createNavigationController,
|
||||
validateSectionAnchor,
|
||||
type NavigationState,
|
||||
type NavigationRuntime,
|
||||
} from "@/journey/navigation-controller";
|
||||
import type { SectionId } from "@/content/types";
|
||||
@@ -205,6 +206,7 @@ describe("navigation epochs and effects", () => {
|
||||
expect(controller.state()).toEqual({
|
||||
epoch: 2,
|
||||
source: "passive-scroll",
|
||||
phase: "idle",
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
});
|
||||
@@ -221,6 +223,30 @@ describe("navigation epochs and effects", () => {
|
||||
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", () => {
|
||||
const harness = createRuntime(false);
|
||||
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||
@@ -271,7 +297,9 @@ describe("navigation epochs and effects", () => {
|
||||
const controller = createNavigationController(sectionIds, harness.runtime);
|
||||
|
||||
expect(controller.initialize("toolkit", target("toolkit"))).toBe(1);
|
||||
expect(controller.state()).toMatchObject({ phase: "settled" });
|
||||
expect(controller.restore("profile", target("profile"))).toBe(2);
|
||||
expect(controller.state()).toMatchObject({ phase: "travelling" });
|
||||
harness.runFrame();
|
||||
expect(harness.pushes).toEqual([]);
|
||||
expect(harness.replacements).toEqual([]);
|
||||
@@ -283,6 +311,7 @@ describe("navigation epochs and effects", () => {
|
||||
expect(controller.state()).toEqual({
|
||||
epoch: 2,
|
||||
source: "history-restoration",
|
||||
phase: "settled",
|
||||
destinationId: "profile",
|
||||
signDestinationId: null,
|
||||
});
|
||||
|
||||
@@ -582,6 +582,11 @@ function journey(overrides: Partial<JourneySnapshot> = {}): JourneySnapshot {
|
||||
chapter: 'deep-night',
|
||||
sign: null,
|
||||
arrived: false,
|
||||
sectionActivation: {
|
||||
layoutRevision: 0,
|
||||
activeHeadingOffsetVh: 0,
|
||||
nextHeadingOffsetVh: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -465,6 +465,7 @@ describe('useScrollDrive', () => {
|
||||
const navigation = shallowRef({
|
||||
epoch: 0,
|
||||
source: 'passive-scroll' as const,
|
||||
phase: 'idle' as const,
|
||||
destinationId: null,
|
||||
signDestinationId: null,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activationLineDocumentY,
|
||||
deriveSectionActivationSnapshot,
|
||||
deriveSectionState,
|
||||
isAtDocumentEnd,
|
||||
selectActiveSection,
|
||||
@@ -109,6 +110,41 @@ describe("active section selection", () => {
|
||||
).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", () => {
|
||||
expect([
|
||||
deriveSectionState("start"),
|
||||
|
||||
Reference in New Issue
Block a user