feat: expose stable journey intent signals

This commit is contained in:
2026-09-04 15:25:35 +03:00
parent c73410a956
commit 7632c8b9c0
12 changed files with 197 additions and 9 deletions

View File

@@ -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);
},
};
}

View File

@@ -43,6 +43,7 @@ export function useKeyboardNavigation(
const state = shallowRef<NavigationState>({
epoch: 0,
source: "initial",
phase: "idle",
destinationId: null,
signDestinationId: null,
});