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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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