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

@@ -28,6 +28,11 @@ const journey = (
chapter: "infrastructure",
sign: null,
arrived: false,
sectionActivation: {
layoutRevision: 0,
activeHeadingOffsetVh: 0,
nextHeadingOffsetVh: null,
},
...overrides,
});

View File

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

View File

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

View File

@@ -465,6 +465,7 @@ describe('useScrollDrive', () => {
const navigation = shallowRef({
epoch: 0,
source: 'passive-scroll' as const,
phase: 'idle' as const,
destinationId: null,
signDestinationId: null,
});

View File

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