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

@@ -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(

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

View File

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

View File

@@ -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",

View File

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

View File

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