diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 86dbb0c..7c78d22 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -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(null); let audioNavigationEpoch = -1; watch( diff --git a/src/composables/useJourneySections.ts b/src/composables/useJourneySections.ts index 559a24e..7b3b3b3 100644 --- a/src/composables/useJourneySections.ts +++ b/src/composables/useJourneySections.ts @@ -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>; + requestFrame(): void; + subscribeFrame( + listener: (now: number, snapshot: JourneySnapshot) => boolean, + ): () => void; +} + export function useJourneySections( canonicalIds: readonly SectionId[], navigationState: Readonly>, motionAllowed?: Readonly>, -): Readonly> { +): 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); + }, + }; } diff --git a/src/composables/useKeyboardNavigation.ts b/src/composables/useKeyboardNavigation.ts index 4b25b17..7631cc2 100644 --- a/src/composables/useKeyboardNavigation.ts +++ b/src/composables/useKeyboardNavigation.ts @@ -43,6 +43,7 @@ export function useKeyboardNavigation( const state = shallowRef({ epoch: 0, source: "initial", + phase: "idle", destinationId: null, signDestinationId: null, }); diff --git a/src/journey/navigation-controller.ts b/src/journey/navigation-controller.ts index 7d07322..b0ec8e0 100644 --- a/src/journey/navigation-controller.ts +++ b/src/journey/navigation-controller.ts @@ -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(); } }); } diff --git a/src/journey/section-controller.ts b/src/journey/section-controller.ts index 888ab00..8d399fc 100644 --- a/src/journey/section-controller.ts +++ b/src/journey/section-controller.ts @@ -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> = { start: "deep-night", profile: "deep-night", diff --git a/src/journey/types.ts b/src/journey/types.ts index aae1e92..d66962c 100644 --- a/src/journey/types.ts +++ b/src/journey/types.ts @@ -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; } diff --git a/src/scene/create-scene-controller.ts b/src/scene/create-scene-controller.ts index 9d6f19f..8363bb7 100644 --- a/src/scene/create-scene-controller.ts +++ b/src/scene/create-scene-controller.ts @@ -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, + }, }; } diff --git a/tests/unit/SceneCanvas.test.ts b/tests/unit/SceneCanvas.test.ts index 3c10eb4..3b6e21b 100644 --- a/tests/unit/SceneCanvas.test.ts +++ b/tests/unit/SceneCanvas.test.ts @@ -28,6 +28,11 @@ const journey = ( chapter: "infrastructure", sign: null, arrived: false, + sectionActivation: { + layoutRevision: 0, + activeHeadingOffsetVh: 0, + nextHeadingOffsetVh: null, + }, ...overrides, }); diff --git a/tests/unit/navigation-controller.test.ts b/tests/unit/navigation-controller.test.ts index 49a7a1a..3abcc26 100644 --- a/tests/unit/navigation-controller.test.ts +++ b/tests/unit/navigation-controller.test.ts @@ -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, }); diff --git a/tests/unit/scene-lifecycle.test.ts b/tests/unit/scene-lifecycle.test.ts index 25551a6..08bfcf2 100644 --- a/tests/unit/scene-lifecycle.test.ts +++ b/tests/unit/scene-lifecycle.test.ts @@ -582,6 +582,11 @@ function journey(overrides: Partial = {}): JourneySnapshot { chapter: 'deep-night', sign: null, arrived: false, + sectionActivation: { + layoutRevision: 0, + activeHeadingOffsetVh: 0, + nextHeadingOffsetVh: null, + }, ...overrides, }; } diff --git a/tests/unit/scroll-drive-controller.test.ts b/tests/unit/scroll-drive-controller.test.ts index f18475e..fd3adee 100644 --- a/tests/unit/scroll-drive-controller.test.ts +++ b/tests/unit/scroll-drive-controller.test.ts @@ -465,6 +465,7 @@ describe('useScrollDrive', () => { const navigation = shallowRef({ epoch: 0, source: 'passive-scroll' as const, + phase: 'idle' as const, destinationId: null, signDestinationId: null, }); diff --git a/tests/unit/section-controller.test.ts b/tests/unit/section-controller.test.ts index 03bd658..b0dc5fb 100644 --- a/tests/unit/section-controller.test.ts +++ b/tests/unit/section-controller.test.ts @@ -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"),